30-Day NHPC Stack Development Course - Master Next.js, Hono, Prisma & Cloudflare
Build modern full-stack applications with the NHPC stack - the powerful alternative to MERN. Learn Next.js 15, Hono API framework, Prisma ORM, PostgreSQL, and TypeScript through 6 production-ready projects including a recipe platform, job board, social media dashboard, and multi-vendor marketplace. Complete with authentication, real-time features, and edge deployment.
30-Day NHPC Stack Development Course
Next.js + Hono + Prisma + Cloudflare/Vercel
The Modern Alternative to MERN Stack
Course Duration: 30 Days (December 2, 2025 \- December 30, 2025\)
Study Days: Tuesday, Thursday, Saturday (3 days per week)
Total Sessions: 13 Sessions
Stack Name: NHPC (Next.js \+ Hono \+ Prisma \+ Cloudflare)
What is NHPC Stack?
The Modern Evolution Beyond MERN
Traditional MERN:
- MongoDB
(Database) - Express.js
(Backend Framework) - React
(Frontend Library) - Node.js
(Runtime)
Modern NHPC:
- Next.js (Frontend Framework - React on steroids)
- Hono (Ultra-fast backend framework - Express alternative)
- Prisma (Type-safe ORM - MongoDB alternative with SQL)
- Cloudflare/Vercel (Edge runtime + deployment)
- PostgreSQL (Robust relational database)
- TypeScript (Type safety throughout)
Why NHPC Over MERN?
✅ Type Safety - End-to-end TypeScript
✅ Performance - Edge runtime, faster responses
✅ Modern DX - Better developer experience
✅ Scalability - Built for production from day 1
✅ SQL Power - Relational data with Prisma
✅ API-First - Clean separation of concerns
✅ Edge Deployment - Global distribution
Course Structure Overview
Phase 1: Frontend Foundation (Sessions 1-3)
- Next.js fundamentals, React patterns, TypeScript
- Project 1: Recipe Sharing Platform (Frontend Only)
Phase 2: Backend Foundation (Sessions 4-6)
- Hono API development, Prisma ORM, PostgreSQL
- Project 2: RESTful Blog API
- Project 3: E-commerce Product API
Phase 3: Full-Stack Integration (Sessions 7-9)
- Connecting Next.js frontend with Hono backend
- Project 4: Full-Stack Job Board
Phase 4: Advanced Features (Sessions 10-11)
- Authentication, file uploads, real-time features
- Project 5: Social Media Dashboard
Phase 5: Production Application (Sessions 12-13)
- Complete production-ready system
- Project 6: Multi-Vendor Marketplace Platform
Detailed Session Breakdown
PHASE 1: FRONTEND FOUNDATION
Session 1 - Tuesday, December 2, 2025
Topic: Next.js Fundamentals & Modern React Patterns
Learning Objectives:
- Next.js 15 App Router architecture
- React Server Components vs Client Components
- TypeScript in React/Next.js
- Routing and navigation
- Data fetching patterns
- Layouts and templates
Key Concepts:
- File-based routing system
- Server-first approach
- TypeScript interfaces for props
- Component composition
- Error boundaries
- Loading states
Next.js Project Setup:
// app/layout.tsx - Root Layout
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata \= {
title: 'NHPC App',
description: 'Built with Next.js, Hono, Prisma, Cloudflare',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
\<html lang="en"\>
\<body\>{children}\</body\>
\</html\>
)
}
// app/page.tsx \- Server Component (default)
export default async function HomePage() {
// Can fetch data directly on server
const data \= await fetch('https://api.example.com/data')
const json \= await data.json()
return \<div\>{/\* Render data \*/}\</div\>
}
// components/Counter.tsx \- Client Component
'use client'
import { useState } from "react";
export function Counter() {
const \[count, setCount\] \= useState(0)
return (
\<button onClick={() \=\> setCount(count \+ 1)}\>
Count: {count}
\</button\>
)
}TypeScript Patterns:
// types/index.ts
export interface User {
id: string
name: string
email: string
avatar?: string
}
export interface Recipe {
id: string
title: string
description: string
ingredients: Ingredient\[\]
steps: string\[\]
cookTime: number
servings: number
author: User
createdAt: Date
}
export interface Ingredient {
name: string
amount: string
unit: string
}// Component with typed props
interface RecipeCardProps {
recipe: Recipe
onLike?: (id: string) \=\> void
}
export function RecipeCard({ recipe, onLike }: RecipeCardProps) {
return \<div\>{/\* Component \*/}\</div\>
}Practical Exercises:
- Create Next.js project with TypeScript
- Build multi-page application
- Implement nested layouts
- Create typed components
- Add loading and error states
Homework:
- Set up Next.js project
- Create 5 pages with proper routing
- Implement shared layout with navigation
- Type all components
Session 2 - Thursday, December 4, 2025
Topic: Advanced Next.js Patterns & State Management
Learning Objectives:
- Client-side state management (Context API, Zustand, Simple store (https://simple-stack.dev/store))
- Form handling and validation (React Hook Form, Zod)
- Data fetching strategies
- Parallel and sequential data fetching
- Streaming and Suspense
- Image optimization
State Management with Zustand:
// store/useRecipeStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface RecipeState {
favorites: string\[\]
addFavorite: (id: string) \=\> void
removeFavorite: (id: string) \=\> void
isFavorite: (id: string) \=\> boolean
}
export const useRecipeStore \= create\<RecipeState\>()(
persist(
(set, get) \=\> ({
favorites: \[\],
addFavorite: (id) \=\>
set((state) \=\> ({
favorites: \[...state.favorites, id\],
})),
removeFavorite: (id) \=\>
set((state) \=\> ({
favorites: state.favorites.filter((fav) \=\> fav \!== id),
})),
isFavorite: (id) \=\> get().favorites.includes(id),
}),
{
name: 'recipe-storage',
}
)
)Form Validation with Zod:
// lib/validations/recipe.ts
import { z } from 'zod'
export const recipeSchema \= z.object({
title: z.string().min(3).max(100),
description: z.string().min(10).max(500),
cookTime: z.number().min(1).max(480),
servings: z.number().min(1).max(50),
ingredients: z.array(
z.object({
name: z.string().min(1),
amount: z.string().min(1),
unit: z.string().min(1),
})
).min(1),
steps: z.array(z.string().min(1)).min(1),
})
export type RecipeInput \= z.infer\<typeof recipeSchema\>Form Handling:
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { recipeSchema, type RecipeInput } from '@/lib/validations/recipe'
export function RecipeForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} \= useForm\<RecipeInput\>({
resolver: zodResolver(recipeSchema),
})
async function onSubmit(data: RecipeInput) {
const response \= await fetch('/api/recipes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
// Handle success
}
}
return (
\<form onSubmit={handleSubmit(onSubmit)}\>
\<input {...register('title')} /\>
{errors.title && \<span\>{errors.title.message}\</span\>}
\<button type="submit" disabled={isSubmitting}\>
{isSubmitting ? 'Submitting...' : 'Submit'}
\</button\>
\</form\>
)
}Practical Exercises:
- Set up Zustand for state management
- Create forms with validation
- Implement data fetching patterns
- Add loading and error states
- Optimize images with Next.js Image
Homework:
- Implement global state management
- Create validated forms
- Add image optimization
Session 3 - Saturday, December 6, 2025
Topic: PROJECT 1 - Recipe Sharing Platform (Frontend Only)
Project Requirements: Build a complete recipe sharing platform frontend (mock data for now)
Features:
- Homepage with featured recipes
- Browse all recipes with filters
- Search functionality
- Recipe detail page with ingredients and steps
- Create/Edit recipe form (save to localStorage)
- User favorites (localStorage)
- Category filtering
- Responsive design
- Image galleries
- Print recipe view
- Share functionality
Technical Requirements:
- Next.js App Router
- TypeScript throughout
- Zustand for state management
- React Hook Form + Zod validation
- Tailwind CSS for styling
- Next.js Image optimization
- Mock data (JSON files)
- LocalStorage for persistence
- Proper SEO meta tags
File Structure:
app/
├── layout.tsx
├── page.tsx (home)
├── recipes/
│ ├── page.tsx (browse)
│ ├── [id]/
│ │ └── page.tsx (detail)
│ ├── new/
│ │ └── page.tsx (create)
│ └── [id]/
│ └── edit/
│ └── page.tsx
├── favorites/
│ └── page.tsx
├── search/
│ └── page.tsx
components/
├── recipes/
│ ├── RecipeCard.tsx
│ ├── RecipeForm.tsx
│ ├── RecipeDetail.tsx
│ ├── RecipeSearch.tsx
│ └── RecipeFilters.tsx
├── ui/
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Card.tsx
store/
├── useRecipeStore.ts
└── useFavoritesStore.ts
lib/
├── validations/
│ └── recipe.ts
└── data/
└── mock-recipes.ts
types/
└── index.ts
Mock Data Structure:
// lib/data/mock-recipes.ts
export const mockRecipes: Recipe\[\] \= \[
{
id: '1',
title: 'Classic Margherita Pizza',
description: 'Authentic Italian pizza with fresh ingredients',
ingredients: \[
{ name: 'Pizza dough', amount: '1', unit: 'ball' },
{ name: 'Tomato sauce', amount: '1/2', unit: 'cup' },
{ name: 'Fresh mozzarella', amount: '200', unit: 'g' },
{ name: 'Fresh basil', amount: '10', unit: 'leaves' },
\],
steps: \[
'Preheat oven to 500°F',
'Roll out pizza dough',
'Spread tomato sauce',
'Add mozzarella cheese',
'Bake for 10-12 minutes',
'Add fresh basil leaves',
\],
cookTime: 30,
servings: 4,
category: 'Italian',
difficulty: 'Medium',
image: '/images/pizza.jpg',
author: {
id: '1',
name: 'Chef Mario',
avatar: '/avatars/mario.jpg',
},
createdAt: new Date('2025-01-01'),
},
// More recipes...
\]Skills Applied:
- Next.js routing and layouts
- TypeScript interfaces
- State management
- Form handling and validation
- Client-side data persistence
- Responsive design
- Image optimization
- SEO optimization
Deliverable: Fully functional recipe platform frontend with mock data
PHASE 2: BACKEND FOUNDATION
Session 4 - Tuesday, December 10, 2025
Topic: Hono Framework & API Development Fundamentals
Learning Objectives:
- Hono framework overview and setup
- RESTful API design principles
- Route handling and middleware
- Request/response patterns
- Error handling
- CORS configuration
- Environment variables
- API documentation patterns
Why Hono?
- Ultra-fast - Faster than Express
- Edge-ready - Works on Cloudflare Workers, Vercel Edge
- Type-safe - Full TypeScript support
- Lightweight - Minimal overhead
- Modern DX - Clean, intuitive API
Hono Setup:
// server/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { prettyJSON } from 'hono/pretty-json'
const app \= new Hono()
// Middleware
app.use('\*', logger())
app.use('\*', prettyJSON())
app.use('\*', cors({
origin: \['http://localhost:3000'\],
credentials: true,
}))
// Routes
app.get('/', (c) \=\> {
return c.json({
message: 'Welcome to NHPC API',
version: '1.0.0',
})
})
// Health check
app.get('/health', (c) \=\> {
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
})
})
export default app;Route Organization:
// server/routes/recipes.ts
import { Hono } from 'hono'
import type { Recipe } from '../types'
const recipes \= new Hono()
// GET all recipes
recipes.get('/', async (c) \=\> {
const { page \= '1', limit \= '10', category } \= c.req.query()
// Database query would go here
const data: Recipe\[\] \= \[\]
return c.json({
data,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: 100,
},
})
})
// GET single recipe
recipes.get('/:id', async (c) \=\> {
const id \= c.req.param('id')
// Database query
const recipe \= {} as Recipe
if (\!recipe) {
return c.json({ error: 'Recipe not found' }, 404\)
}
return c.json({ data: recipe })
})
// POST create recipe
recipes.post('/', async (c) \=\> {
const body \= await c.req.json()
// Validation
// Database insert
return c.json({ data: {} as Recipe }, 201\)
})
// PATCH update recipe
recipes.patch('/:id', async (c) \=\> {
const id \= c.req.param('id')
const body \= await c.req.json()
// Validation
// Database update
return c.json({ data: {} as Recipe })
})
// DELETE recipe
recipes.delete('/:id', async (c) \=\> {
const id \= c.req.param('id')
// Database delete
return c.json({ message: 'Recipe deleted' })
})
export default recipes;
// server/index.ts \- Mount routes
import recipes from './routes/recipes'
app.route('/api/recipes', recipes)Middleware Pattern:
// server/middleware/auth.ts
import type { Context, Next } from 'hono'
export async function authMiddleware(c: Context, next: Next) {
const token \= c.req.header('Authorization')?.replace('Bearer ', '')
if (\!token) {
return c.json({ error: 'Unauthorized' }, 401\)
}
try {
// Verify token
const user \= { id: '1', email: 'user@example.com' }
c.set('user', user)
await next()
} catch (error) {
return c.json({ error: 'Invalid token' }, 401\)
}
}
// Usage
recipes.post('/', authMiddleware, async (c) \=\> {
const user \= c.get('user')
// Create recipe for authenticated user
})
Error Handling:
// server/middleware/error.ts
import { Context } from 'hono'
export function errorHandler(err: Error, c: Context) {
console.error('Error:', err)
return c.json({
error: err.message || 'Internal Server Error',
stack: process.env.NODE\_ENV \=== 'development' ? err.stack : undefined,
}, 500\)
}
// server/index.ts
`app.onError(errorHandler)`;Practical Exercises:
- Set up Hono project
- Create RESTful routes
- Implement middleware
- Add error handling
- Configure CORS
- Test with Postman/Thunder Client
Homework:
- Create Hono API server
- Implement CRUD routes
- Add middleware
- Test all endpoints
Session 5 - Thursday, December 12, 2025
Topic: PostgreSQL, Prisma ORM & Database Design
Learning Objectives:
- PostgreSQL setup (Neon, Supabase, or local)
- Prisma installation and configuration
- Schema design for relational data
- Migrations workflow
- CRUD operations with Prisma
- Relations and joins
- Query optimization
- Seeding database
Prisma Setup:
pnpm add prisma @prisma/client
npx prisma init
Database Schema:
// prisma/schema.prisma
generator client {
provider \= "prisma-client-js"
}
datasource db {
provider \= "postgresql"
url \= env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
avatar String?
bio String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipes Recipe\[\]
comments Comment\[\]
likes Like\[\]
@@index(\[email\])
}
model Recipe {
id String @id @default(cuid())
title String
description String @db.Text
ingredients Json // Array of ingredients
steps String\[\] // Array of steps
cookTime Int // in minutes
servings Int
category String
difficulty String
image String?
published Boolean @default(false)
authorId String
author User @relation(fields: \[authorId\], references: \[id\], onDelete: Cascade)
comments Comment\[\]
likes Like\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[authorId\])
@@index(\[category\])
@@index(\[published\])
}
model Comment {
id String @id @default(cuid())
content String @db.Text
recipeId String
recipe Recipe @relation(fields: \[recipeId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[recipeId\])
@@index(\[userId\])
}
model Like {
id String @id @default(cuid())
recipeId String
recipe Recipe @relation(fields: \[recipeId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique(\[recipeId, userId\])
@@index(\[recipeId\])
@@index(\[userId\])
}
Prisma Client Setup:
// server/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma \= globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma \= globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE\_ENV \=== 'development' ? \['query', 'error', 'warn'\] : \['error'\],
})
if (process.env.NODE_ENV \!== 'production') {
globalForPrisma.prisma \= prisma
}CRUD Operations:
// server/controllers/recipes.ts
import { prisma } from '../lib/prisma'
import type { Context } from 'hono'
export const recipeController \= {
// Get all recipes
async getAll(c: Context) {
const { page \= '1', limit \= '10', category, search } \= c.req.query()
const skip \= (parseInt(page) \- 1\) \* parseInt(limit)
const where \= {
published: true,
...(category && { category }),
...(search && {
OR: \[
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
\],
}),
}
const \[recipes, total\] \= await Promise.all(\[
prisma.recipe.findMany({
where,
skip,
take: parseInt(limit),
include: {
author: {
select: {
id: true,
name: true,
avatar: true,
},
},
\_count: {
select: {
likes: true,
comments: true,
},
},
},
orderBy: { createdAt: 'desc' },
}),
prisma.recipe.count({ where }),
\])
return c.json({
data: recipes,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit)),
},
})
},
// Get single recipe
async getById(c: Context) {
const id \= c.req.param('id')
const recipe \= await prisma.recipe.findUnique({
where: { id },
include: {
author: {
select: {
id: true,
name: true,
avatar: true,
bio: true,
},
},
comments: {
include: {
user: {
select: {
id: true,
name: true,
avatar: true,
},
},
},
orderBy: { createdAt: 'desc' },
},
\_count: {
select: { likes: true },
},
},
})
if (\!recipe) {
return c.json({ error: 'Recipe not found' }, 404\)
}
return c.json({ data: recipe })
},
// Create recipe
async create(c: Context) {
const body \= await c.req.json()
const user \= c.get('user')
const recipe \= await prisma.recipe.create({
data: {
...body,
authorId: user.id,
},
include: {
author: {
select: {
id: true,
name: true,
avatar: true,
},
},
},
})
return c.json({ data: recipe }, 201\)
},
// Update recipe
async update(c: Context) {
const id \= c.req.param('id')
const body \= await c.req.json()
const user \= c.get('user')
// Check ownership
const existing \= await prisma.recipe.findUnique({
where: { id },
select: { authorId: true },
})
if (\!existing || existing.authorId \!== user.id) {
return c.json({ error: 'Forbidden' }, 403\)
}
const recipe \= await prisma.recipe.update({
where: { id },
data: body,
})
return c.json({ data: recipe })
},
// Delete recipe
async delete(c: Context) {
const id \= c.req.param('id')
const user \= c.get('user')
const existing \= await prisma.recipe.findUnique({
where: { id },
select: { authorId: true },
})
if (\!existing || existing.authorId \!== user.id) {
return c.json({ error: 'Forbidden' }, 403\)
}
await prisma.recipe.delete({ where: { id } })
return c.json({ message: 'Recipe deleted' })
},
}Database Seeding:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma \= new PrismaClient()
async function main() {
// Create users
const user1 \= await prisma.user.create({
data: {
email: 'chef@example.com',
name: 'Chef Mario',
avatar: '/avatars/mario.jpg',
bio: 'Professional Italian chef',
},
})
// Create recipes
await prisma.recipe.create({
data: {
title: 'Classic Margherita Pizza',
description: 'Authentic Italian pizza',
ingredients: \[
{ name: 'Pizza dough', amount: '1', unit: 'ball' },
{ name: 'Tomato sauce', amount: '1/2', unit: 'cup' },
\],
steps: \[
'Preheat oven to 500°F',
'Roll out pizza dough',
\],
cookTime: 30,
servings: 4,
category: 'Italian',
difficulty: 'Medium',
published: true,
authorId: user1.id,
},
})
console.log('Database seeded\!')
}
main()
.catch((e) \=\> {
console.error(e)
process.exit(1)
})
.finally(async () \=\> {
await prisma.$disconnect()
})
Practical Exercises:
- Set up PostgreSQL database
- Install and configure Prisma
- Design and create schema
- Run migrations
- Implement CRUD operations
- Seed database with test data
Homework:
- Complete database schema
- Implement all Prisma queries
- Test database operations
- Create seed data
Session 6 - Saturday, December 14, 2025
Topic: PROJECT 2 & 3 - RESTful APIs
PROJECT 2: Blog API
Project Requirements: Complete RESTful API for a blog platform
Features:
- User management (CRUD)
- Blog post management (CRUD)
- Categories and tags
- Comments system
- Post likes/reactions
- Search and filtering
- Pagination
- Author profiles
- Draft/published status
Technical Requirements:
- Hono framework
- PostgreSQL + Prisma
- TypeScript
- Input validation (Zod)
- Error handling
- API documentation
- Testing setup
Database Schema:
model User {
id String @id @default(cuid())
email String @unique
username String @unique
name String
bio String? @db.Text
avatar String?
posts Post\[\]
comments Comment\[\]
likes Like\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[username\])
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String @db.Text
excerpt String?
coverImage String?
published Boolean @default(false)
authorId String
author User @relation(fields: \[authorId\], references: \[id\], onDelete: Cascade)
categoryId String?
category Category? @relation(fields: \[categoryId\], references: \[id\])
tags Tag\[\]
comments Comment\[\]
likes Like\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
@@index(\[authorId\])
@@index(\[slug\])
@@index(\[published\])
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
posts Post\[\]
}
model Tag {
id String @id @default(cuid())
name String @unique
slug String @unique
posts Post\[\]
}
model Comment {
id String @id @default(cuid())
content String @db.Text
postId String
post Post @relation(fields: \[postId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[postId\])
@@index(\[userId\])
}
model Like {
id String @id @default(cuid())
postId String
post Post @relation(fields: \[postId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique(\[postId, userId\])
}API Endpoints:
Users:
GET /api/users
GET /api/users/:id
POST /api/users
PATCH /api/users/:id
DELETE /api/users/:id
Posts:
GET /api/posts (with pagination, filtering)
GET /api/posts/:slug
POST /api/posts
PATCH /api/posts/:id
DELETE /api/posts/:id
GET /api/posts/:id/comments
POST /api/posts/:id/like
Categories:
GET /api/categories
POST /api/categories
Tags:
GET /api/tags
POST /api/tags
Comments:
POST /api/comments
DELETE /api/comments/:id
Deliverable: Complete blog API with documentation
PROJECT 3: E-commerce Product API
Project Requirements: RESTful API for e-commerce product management
Features:
- Product catalog (CRUD)
- Categories and subcategories
- Product variants (size, color, etc.)
- Inventory tracking
- Price management
- Product reviews and ratings
- Search and filtering
- Sorting options
- Product images
- Related products
Database Schema:
model Product {
id String @id @default(cuid())
name String
slug String @unique
description String @db.Text
basePrice Decimal @db.Decimal(10, 2\)
sku String @unique
categoryId String
category Category @relation(fields: \[categoryId\], references: \[id\])
variants ProductVariant\[\]
images ProductImage\[\]
reviews Review\[\]
published Boolean @default(false)
featured Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[categoryId\])
@@index(\[slug\])
@@index(\[published\])
}
model Category {
id String @id @default(cuid())
name String
slug String @unique
parentId String?
parent Category? @relation("CategoryTree", fields: \[parentId\], references: \[id\])
children Category\[\] @relation("CategoryTree")
products Product\[\]
}
model ProductVariant {
id String @id @default(cuid())
productId String
product Product @relation(fields: \[productId\], references: \[id\], onDelete: Cascade)
sku String @unique
name String // e.g., "Medium / Blue"
price Decimal @db.Decimal(10, 2\)
stock Int @default(0)
attributes Json // { size: "M", color: "Blue" }
@@index(\[productId\])
}
model ProductImage {
id String @id @default(cuid())
productId String
product Product @relation(fields: \[productId\], references: \[id\], onDelete: Cascade)
url String
alt String?
order Int @default(0)
@@index(\[productId\])
}
model Review {
id String @id @default(cuid())
productId String
product Product @relation(fields: \[productId\], references: \[id\], onDelete: Cascade)
userId String
rating Int // 1-5
title String
comment String @db.Text
verified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[productId\])
@@index(\[userId\])
}API Endpoints:
Products:
GET /api/products (search, filter, sort, pagination)
GET /api/products/:slug
POST /api/products
PATCH /api/products/:id
DELETE /api/products/:id
GET /api/products/:id/variants
GET /api/products/:id/reviews
Categories:
GET /api/categories (hierarchical)
POST /api/categories
Reviews:
POST /api/reviews
GET /api/products/:id/reviews
Skills Applied (Projects 2 & 3):
- Hono route organization
- Prisma complex queries
- Data validation
- Error handling
- Pagination logic
- Search and filtering
- Relational data
- API best practices
Deliverable: Two complete RESTful APIs
PHASE 3: FULL-STACK INTEGRATION
Session 7 - Tuesday, December 17, 2025
Topic: Connecting Next.js Frontend with Hono Backend
Learning Objectives:
- API integration patterns
- Environment variables in Next.js
- TypeScript API types sharing
- Error handling in requests
- Loading states
- Optimistic updates
- SWR for data fetching
- API route proxying
API Client Setup:
// frontend/lib/api.ts
import type { Recipe } from '@/types'
const API_URL \= process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8787'
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
async function fetchApi\<T\>(
endpoint: string,
options?: RequestInit
): Promise\<T\> {
const url \= \`${API\_URL}${endpoint}\`
const response \= await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (\!response.ok) {
const error \= await response.json()
throw new ApiError(response.status, error.message || 'An error occurred')
}
return response.json()
}
export const api \= {
// Recipes
recipes: {
getAll: (params?: {
page?: number
limit?: number
category?: string
search?: string
}) \=\> {
const searchParams \= new URLSearchParams()
if (params) {
Object.entries(params).forEach((\[key, value\]) \=\> {
if (value \!== undefined) {
searchParams.append(key, value.toString())
}
})
}
return fetchApi\<{ data: Recipe\[\]; pagination: any }\>(
\`/api/recipes?${searchParams}\`
)
},
getById: (id: string) \=\>
fetchApi\<{ data: Recipe }\>(\`/api/recipes/${id}\`),
create: (data: Partial\<Recipe\>) \=\>
fetchApi\<{ data: Recipe }\>('/api/recipes', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: Partial\<Recipe\>) \=\>
fetchApi\<{ data: Recipe }\>(\`/api/recipes/${id}\`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: (id: string) \=\>
fetchApi\<{ message: string }\>(\`/api/recipes/${id}\`, {
method: 'DELETE',
}),
},
}Using SWR for Data Fetching:
// hooks/useRecipes.ts
import useSWR from 'swr'
import { api } from '@/lib/api'
export function useRecipes(params?: {
page?: number
category?: string
search?: string
}) {
const { data, error, isLoading, mutate } \= useSWR(
\['/api/recipes', params\],
() \=\> api.recipes.getAll(params)
)
return {
recipes: data?.data,
pagination: data?.pagination,
isLoading,
isError: error,
mutate,
}
}
export function useRecipe(id: string) {
const { data, error, isLoading, mutate } \= useSWR(
id ? \`/api/recipes/${id}\` : null,
() \=\> api.recipes.getById(id)
)
return {
recipe: data?.data,
isLoading,
isError: error,
mutate,
}
}Server Component Data Fetching:
// app/recipes/page.tsx
import { api } from '@/lib/api'
import { RecipeCard } from '@/components/recipes/RecipeCard'
export default async function RecipesPage({
searchParams,
}: {
searchParams: { page?: string; category?: string; search?: string }
}) {
const { data: recipes, pagination } \= await api.recipes.getAll({
page: searchParams.page ? parseInt(searchParams.page) : 1,
category: searchParams.category,
search: searchParams.search,
})
return (
\<div\>
\<h1\>Recipes\</h1\>
\<div className="grid grid-cols-3 gap-4"\>
{recipes.map((recipe) \=\> (
\<RecipeCard key={recipe.id} recipe={recipe} /\>
))}
\</div\>
{/\* Pagination component \*/}
\</div\>
)
}Client Component with Mutations:
// components/recipes/CreateRecipeForm.tsx
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { api } from '@/lib/api'
import { useRouter } from 'next/navigation'
import { recipeSchema, type RecipeInput } from '@/lib/validations/recipe'
export function CreateRecipeForm() {
const router \= useRouter()
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} \= useForm\<RecipeInput\>({
resolver: zodResolver(recipeSchema),
})
async function onSubmit(data: RecipeInput) {
try {
const result \= await api.recipes.create(data)
router.push(\`/recipes/${result.data.id}\`)
} catch (error) {
console.error('Error creating recipe:', error)
// Show error toast
}
}
return (
\<form onSubmit={handleSubmit(onSubmit)}\>
{/\* Form fields \*/}
\<button type="submit" disabled={isSubmitting}\>
{isSubmitting ? 'Creating...' : 'Create Recipe'}
\</button\>
\</form\>
)
}Type Sharing Between Frontend and Backend:
// shared/types.ts (in a shared package or copied to both projects)
export interface Recipe {
id: string
title: string
description: string
ingredients: Ingredient\[\]
steps: string\[\]
cookTime: number
servings: number
category: string
difficulty: string
image?: string
author: {
id: string
name: string
avatar?: string
}
createdAt: Date
updatedAt: Date
}
export interface ApiResponse\<T\> {
data: T
error?: string
}
export interface PaginatedResponse\<T\> {
data: T\[\]
pagination: {
page: number
limit: number
total: number
pages: number
}
}Practical Exercises:
- Set up API client
- Implement SWR hooks
- Create forms with mutations
- Handle loading and error states
- Share types between projects
Homework:
- Connect frontend to backend
- Implement all CRUD operations
- Add error handling
- Test full integration
Session 8 - Thursday, December 19, 2025
Topic: Authentication & Authorization
Learning Objectives:
- JWT authentication
- Session management
- Protected routes (frontend & backend)
- Role-based access control
- Password hashing (bcrypt)
- Refresh tokens
- OAuth integration (optional)
Backend Authentication:
// server/lib/auth.ts
import { sign, verify } from 'hono/jwt'
import { hash, compare } from 'bcrypt'
const JWT_SECRET \= process.env.JWT_SECRET\!
export const auth \= {
async hashPassword(password: string): Promise\<string\> {
return hash(password, 10\)
},
async comparePassword(password: string, hash: string): Promise\<boolean\> {
return compare(password, hash)
},
async generateToken(userId: string): Promise\<string\> {
return sign(
{
sub: userId,
exp: Math.floor(Date.now() / 1000\) \+ 60 \* 60 \* 24 \* 7, // 7 days
},
JWT_SECRET
)
},
async verifyToken(token: string): Promise\<{ sub: string } | null\> {
try {
return await verify(token, JWT_SECRET)
} catch {
return null
}
},
}
// server/middleware/auth.ts
import { Context, Next } from 'hono'
import { auth } from '../lib/auth'
import { prisma } from '../lib/prisma'
export async function authMiddleware(c: Context, next: Next) {
const authHeader \= c.req.header('Authorization')
const token \= authHeader?.replace('Bearer ', '')
if (\!token) {
return c.json({ error: 'Unauthorized' }, 401\)
}
const payload \= await auth.verifyToken(token)
if (\!payload) {
return c.json({ error: 'Invalid token' }, 401\)
}
const user \= await prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
email: true,
name: true,
role: true,
},
})
if (\!user) {
return c.json({ error: 'User not found' }, 401\)
}
c.set('user', user)
await next()
}
// Auth routes
import { Hono } from 'hono'
import { z } from 'zod'
const authRouter \= new Hono()
const registerSchema \= z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2),
})
const loginSchema \= z.object({
email: z.string().email(),
password: z.string(),
})
authRouter.post('/register', async (c) \=\> {
const body \= await c.req.json()
const validated \= registerSchema.parse(body)
// Check if user exists
const existing \= await prisma.user.findUnique({
where: { email: validated.email },
})
if (existing) {
return c.json({ error: 'User already exists' }, 400\)
}
// Hash password
const hashedPassword \= await auth.hashPassword(validated.password)
// Create user
const user \= await prisma.user.create({
data: {
email: validated.email,
name: validated.name,
password: hashedPassword,
},
select: {
id: true,
email: true,
name: true,
},
})
// Generate token
const token \= await auth.generateToken(user.id)
return c.json({
user,
token,
}, 201\)
})
authRouter.post('/login', async (c) \=\> {
const body \= await c.req.json()
const validated \= loginSchema.parse(body)
// Find user
const user \= await prisma.user.findUnique({
where: { email: validated.email },
})
if (\!user) {
return c.json({ error: 'Invalid credentials' }, 401\)
}
// Check password
const valid \= await auth.comparePassword(validated.password, user.password)
if (\!valid) {
return c.json({ error: 'Invalid credentials' }, 401\)
}
// Generate token
const token \= await auth.generateToken(user.id)
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
},
token,
})
})
authRouter.get('/me', authMiddleware, async (c) \=\> {
const user \= c.get('user')
return c.json({ user })
})
export default authRouter;Frontend Authentication:
// frontend/lib/auth.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
email: string
name: string
}
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
login: (email: string, password: string) \=\> Promise\<void\>
register: (email: string, password: string, name: string) \=\> Promise\<void\>
logout: () \=\> void
}
export const useAuth \= create\<AuthState\>()(
persist(
(set) \=\> ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) \=\> {
const response \= await fetch(\`${API\_URL}/api/auth/login\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (\!response.ok) {
throw new Error('Login failed')
}
const { user, token } \= await response.json()
set({ user, token, isAuthenticated: true })
},
register: async (email, password, name) \=\> {
const response \= await fetch(\`${API\_URL}/api/auth/register\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
})
if (\!response.ok) {
throw new Error('Registration failed')
}
const { user, token } \= await response.json()
set({ user, token, isAuthenticated: true })
},
logout: () \=\> {
set({ user: null, token: null, isAuthenticated: false })
},
}),
{
name: 'auth-storage',
}
)
)
// Update API client to include auth token
export async function fetchApi\<T\>(
endpoint: string,
options?: RequestInit
): Promise\<T\> {
const { token } \= useAuth.getState()
const response \= await fetch(\`${API\_URL}${endpoint}\`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: \`Bearer ${token}\` }),
...options?.headers,
},
})
// Handle response...
}Protected Route Component:
// components/auth/ProtectedRoute.tsx
'use client'
import { useAuth } from "@/lib/auth";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } \= useAuth()
const router \= useRouter()
useEffect(() \=\> {
if (\!isAuthenticated) {
router.push('/login')
}
}, \[isAuthenticated, router\])
if (\!isAuthenticated) {
return \<div\>Loading...\</div\>
}
return \<\>{children}\</\>
}Practical Exercises:
- Implement JWT authentication
- Create login/register forms
- Add protected routes
- Store auth token
- Add auth middleware
Homework:
- Complete authentication system
- Protect API routes
- Add auth to frontend
- Test auth flow
Session 9 - Saturday, December 21, 2025
Topic: PROJECT 4 - Full-Stack Job Board
Project Requirements: Complete job board platform with frontend and backend
Features:
For Job Seekers:
- Browse job listings with filters
- Search jobs by title, company, location
- View job details
- Apply to jobs (with resume upload)
- Save favorite jobs
- User dashboard with applications
- Application status tracking
For Employers:
- Post job listings
- Manage job posts (edit, delete, close)
- View applications
- Filter and search applications
- Mark applications (reviewed, shortlisted, rejected)
- Company profile
General:
- Authentication system
- Role-based access (job seeker vs employer)
- Email notifications
- Responsive design
- Advanced search and filtering
Database Schema:
model User {
id String @id @default(cuid())
email String @unique
password String
name String
role UserRole @default(JOB_SEEKER)// Job Seeker fields
resume String?
phone String?
location String?
skills String\[\]
// Employer fields
companyId String?
company Company? @relation(fields: \[companyId\], references: \[id\])
savedJobs SavedJob\[\]
applications Application\[\]
jobs Job\[\] // Jobs posted by employer
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[email\])
@@index(\[role\])
}
model Company {
id String @id @default(cuid())
name String
logo String?
description String? @db.Text
website String?
location String
industry String
size String?
users User\[\]
jobs Job\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Job {
id String @id @default(cuid())
title String
description String @db.Text
requirements String @db.Text
location String
type JobType // Full-time, Part-time, Contract, Remote
experience String // Entry, Mid, Senior
salary String?
category String
skills String\[\]
status JobStatus @default(OPEN)
companyId String
company Company @relation(fields: \[companyId\], references: \[id\], onDelete: Cascade)
postedById String
postedBy User @relation(fields: \[postedById\], references: \[id\])
applications Application\[\]
savedBy SavedJob\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime?
@@index(\[companyId\])
@@index(\[postedById\])
@@index(\[status\])
@@index(\[category\])
}
model Application {
id String @id @default(cuid())
jobId String
job Job @relation(fields: \[jobId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
coverLetter String? @db.Text
resume String // URL to uploaded resume
status ApplicationStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique(\[jobId, userId\])
@@index(\[jobId\])
@@index(\[userId\])
@@index(\[status\])
}
model SavedJob {
id String @id @default(cuid())
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
jobId String
job Job @relation(fields: \[jobId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique(\[userId, jobId\])
@@index(\[userId\])
}
enum UserRole {
JOB_SEEKER
EMPLOYER
ADMIN
}
enum JobType {
FULL_TIME
PART_TIME
CONTRACT
REMOTE
INTERNSHIP
}
enum JobStatus {
OPEN
CLOSED
FILLED
}
enum ApplicationStatus {
PENDING
REVIEWED
SHORTLISTED
REJECTED
ACCEPTED
}File Structure:
Backend (Hono):
server/
├── index.ts
├── routes/
│ ├── auth.ts
│ ├── jobs.ts
│ ├── applications.ts
│ ├── companies.ts
│ └── users.ts
├── controllers/
│ ├── jobController.ts
│ └── applicationController.ts
├── middleware/
│ ├── auth.ts
│ └── roles.ts
└── lib/
├── prisma.ts
└── email.ts
Frontend (Next.js):
app/
├── (auth)/
│ ├── login/
│ └── register/
├── (main)/
│ ├── jobs/
│ │ ├── page.tsx (browse)
│ │ └── [id]/
│ │ └── page.tsx (detail)
│ ├── dashboard/
│ │ ├── applications/
│ │ ├── saved/
│ │ └── profile/
│ └── employer/
│ ├── jobs/
│ │ ├── page.tsx (manage)
│ │ ├── new/
│ │ └── [id]/
│ │ ├── edit/
│ │ └── applications/
│ └── company/
components/
├── jobs/
│ ├── JobCard.tsx
│ ├── JobFilters.tsx
│ ├── JobSearch.tsx
│ ├── JobForm.tsx
│ └── ApplicationForm.tsx
├── dashboard/
│ └── ApplicationCard.tsx
lib/
├── api.ts
└── auth.ts
Key Features:
Advanced Job Search:
// server/controllers/jobController.ts
export const jobController \= {
async search(c: Context) {
const {
q: query,
location,
type,
category,
experience,
salary,
page \= '1',
limit \= '10',
} \= c.req.query()
const where: any \= {
status: 'OPEN',
}
if (query) {
where.OR \= \[
{ title: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{ skills: { hasSome: query.split(' ') } },
\]
}
if (location) {
where.location \= { contains: location, mode: 'insensitive' }
}
if (type) {
where.type \= type
}
if (category) {
where.category \= category
}
if (experience) {
where.experience \= experience
}
const skip \= (parseInt(page) \- 1\) \* parseInt(limit)
const \[jobs, total\] \= await Promise.all(\[
prisma.job.findMany({
where,
skip,
take: parseInt(limit),
include: {
company: {
select: {
id: true,
name: true,
logo: true,
location: true,
},
},
\_count: {
select: { applications: true },
},
},
orderBy: { createdAt: 'desc' },
}),
prisma.job.count({ where }),
\])
return c.json({
data: jobs,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit)),
},
})
},
}Application System:
// server/controllers/applicationController.ts
export const applicationController \= {
async apply(c: Context) {
const user \= c.get('user')
const { jobId, coverLetter, resume } \= await c.req.json()
// Check if already applied
const existing \= await prisma.application.findUnique({
where: {
jobId\_userId: {
jobId,
userId: user.id,
},
},
})
if (existing) {
return c.json({ error: 'Already applied' }, 400\)
}
const application \= await prisma.application.create({
data: {
jobId,
userId: user.id,
coverLetter,
resume,
},
include: {
job: {
include: {
company: true,
},
},
},
})
// Send notification email to employer
// await sendApplicationNotification(application)
return c.json({ data: application }, 201\)
},
async updateStatus(c: Context) {
const user \= c.get('user')
const { id } \= c.req.param()
const { status } \= await c.req.json()
// Check if user is employer and owns the job
const application \= await prisma.application.findUnique({
where: { id },
include: {
job: true,
},
})
if (\!application || application.job.postedById \!== user.id) {
return c.json({ error: 'Forbidden' }, 403\)
}
const updated \= await prisma.application.update({
where: { id },
data: { status },
})
return c.json({ data: updated })
},
}Skills Applied:
- Full-stack integration
- Authentication & authorization
- Role-based access control
- File uploads
- Complex filtering
- Email notifications
- Multi-user system
Deliverable: Complete job board platform (frontend + backend)
PHASE 4: ADVANCED FEATURES
Session 10 - Tuesday, December 23, 2025
Topic: File Uploads & Cloud Storage
Learning Objectives:
- File upload handling in Hono
- Cloud storage (Cloudflare R2, AWS S3, or UploadThing)
- Image optimization
- File validation
- Presigned URLs
- Direct uploads
File Upload Implementation:
// server/routes/upload.ts
import { Hono } from 'hono'
const upload \= new Hono()
// Using Cloudflare R2 (or AWS S3)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3Client \= new S3Client({
region: 'auto',
endpoint: \`https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com\`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID\!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY\!,
},
})
upload.post('/presigned-url', authMiddleware, async (c) \=\> {
const { filename, contentType } \= await c.req.json()
const key \= \`${Date.now()}-${filename}\`
const command \= new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME,
Key: key,
ContentType: contentType,
})
const url \= await getSignedUrl(s3Client, command, { expiresIn: 3600 })
return c.json({
uploadUrl: url,
fileUrl: \`${process.env.R2\_PUBLIC\_URL}/${key}\`,
})
})
upload.post('/direct', authMiddleware, async (c) \=\> {
const body \= await c.req.parseBody()
const file \= body\['file'\] as File
if (\!file) {
return c.json({ error: 'No file provided' }, 400\)
}
// Validate file
const maxSize \= 5 \* 1024 \* 1024 // 5MB
if (file.size \> maxSize) {
return c.json({ error: 'File too large' }, 400\)
}
const allowedTypes \= \['image/jpeg', 'image/png', 'image/webp', 'application/pdf'\]
if (\!allowedTypes.includes(file.type)) {
return c.json({ error: 'Invalid file type' }, 400\)
}
// Upload to storage
const key \= \`${Date.now()}-${file.name}\`
const buffer \= await file.arrayBuffer()
await s3Client.send(
new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME,
Key: key,
Body: Buffer.from(buffer),
ContentType: file.type,
})
)
const fileUrl \= \`${process.env.R2\_PUBLIC\_URL}/${key}\`
return c.json({ fileUrl })
})
export default upload;Frontend File Upload:
// components/FileUpload.tsx
'use client'
import { useState } from "react";
import { api } from "@/lib/api";
export function FileUpload({ onUploadComplete }: {
onUploadComplete: (url: string) \=\> void
}) {
const \[uploading, setUploading\] \= useState(false)
const \[progress, setProgress\] \= useState(0)
async function handleUpload(e: React.ChangeEvent\<HTMLInputElement\>) {
const file \= e.target.files?.\[0\]
if (\!file) return
setUploading(true)
try {
// Get presigned URL
const { uploadUrl, fileUrl } \= await api.upload.getPresignedUrl({
filename: file.name,
contentType: file.type,
})
// Upload directly to storage
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
onUploadComplete(fileUrl)
} catch (error) {
console.error('Upload failed:', error)
} finally {
setUploading(false)
}
}
return (
\<div\>
\<input
type="file"
onChange={handleUpload}
disabled={uploading}
accept="image/\*,.pdf"
/\>
{uploading && \<div\>Uploading... {progress}%\</div\>}
\</div\>
)
}Practical Exercises:
- Set up cloud storage
- Implement file upload
- Add file validation
- Create upload component
- Test with different file types
Homework:
- Complete file upload system
- Add image optimization
- Implement file deletion
Session 11 - Thursday, December 25, 2025
Topic: PROJECT 5 - Social Media Dashboard
Project Requirements: Social media-style platform with posts, comments, likes
Features:
- User profiles
- Create text/image posts
- Like and comment on posts
- Follow/unfollow users
- News feed (posts from followed users)
- User search
- Notifications
- Real-time updates (polling)
- Image uploads
- Hashtags
- User mentions
Database Schema:
model User {
id String @id @default(cuid())
email String @unique
username String @unique
name String
bio String? @db.Text
avatar String?
posts Post\[\]
comments Comment\[\]
likes Like\[\]
followers Follow\[\] @relation("Followers")
following Follow\[\] @relation("Following")
notifications Notification\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[username\])
}
model Post {
id String @id @default(cuid())
content String @db.Text
images String\[\] // Array of image URLs
authorId String
author User @relation(fields: \[authorId\], references: \[id\], onDelete: Cascade)
comments Comment\[\]
likes Like\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[authorId\])
@@index(\[createdAt\])
}
model Comment {
id String @id @default(cuid())
content String @db.Text
postId String
post Post @relation(fields: \[postId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[postId\])
@@index(\[userId\])
}
model Like {
id String @id @default(cuid())
postId String
post Post @relation(fields: \[postId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique(\[postId, userId\])
@@index(\[postId\])
@@index(\[userId\])
}
model Follow {
id String @id @default(cuid())
followerId String
follower User @relation("Following", fields: \[followerId\], references: \[id\], onDelete: Cascade)
followingId String
following User @relation("Followers", fields: \[followingId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique(\[followerId, followingId\])
@@index(\[followerId\])
@@index(\[followingId\])
}
model Notification {
id String @id @default(cuid())```
```tsx
type NotificationType
message String
read Boolean @default(false)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
actorId String? // User who triggered the notification
postId String?
createdAt DateTime @default(now())
@@index(\[userId, read\])
@@index(\[createdAt\])
}
enum NotificationType {
LIKE
COMMENT
FOLLOW
MENTION
}News Feed Algorithm:
// server/controllers/feedController.ts
export const feedController \= {
async getFeed(c: Context) {
const user \= c.get('user')
const { page \= '1', limit \= '20' } \= c.req.query()
const skip \= (parseInt(page) \- 1\) \* parseInt(limit)
// Get list of users the current user follows
const following \= await prisma.follow.findMany({
where: { followerId: user.id },
select: { followingId: true },
})
const followingIds \= following.map(f \=\> f.followingId)
// Include user's own posts
followingIds.push(user.id)
const posts \= await prisma.post.findMany({
where: {
authorId: { in: followingIds },
},
skip,
take: parseInt(limit),
include: {
author: {
select: {
id: true,
username: true,
name: true,
avatar: true,
},
},
\_count: {
select: {
likes: true,
comments: true,
},
},
likes: {
where: { userId: user.id },
select: { id: true },
},
},
orderBy: { createdAt: 'desc' },
})
// Transform to include isLiked flag
const postsWithLikes \= posts.map(post \=\> ({
...post,
isLiked: post.likes.length \> 0,
likes: undefined, // Remove from response
}))
return c.json({ data: postsWithLikes })
},
}Skills Applied:
- Social features
- Follow system
- News feed algorithm
- Notifications
- Image uploads
- Real-time updates
Deliverable: Social media platform with full features
PHASE 5: PRODUCTION APPLICATION
Sessions 12-13 - Saturday December 27 & Tuesday December 30, 2025
Topic: PROJECT 6 - Multi-Vendor Marketplace Platform (Capstone)
Project Requirements: Complete e-commerce marketplace where multiple vendors can sell products
Features:
For Customers:
- Browse products from multiple vendors
- Advanced search and filtering
- Product details with reviews
- Shopping cart
- Checkout process
- Order tracking
- Wishlist
- User reviews and ratings
- Order history
For Vendors:
- Vendor registration and onboarding
- Product management (CRUD)
- Inventory management
- Order management
- Sales analytics dashboard
- Revenue tracking
- Product variants
- Vendor profile
For Admin:
- Vendor approval
- Platform analytics
- User management
- Order oversight
- Commission management
Database Schema:
model User {
id String @id @default(cuid())
email String @unique
password String
name String
role UserRole @default(CUSTOMER)
phone String?
addresses Address\[\]
cart CartItem\[\]
orders Order\[\]
reviews Review\[\]
wishlist Wishlist\[\]// Vendor specific
vendor Vendor?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[email\])
@@index(\[role\])
}
model Vendor {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
businessName String
description String? @db.Text
logo String?
status VendorStatus @default(PENDING)
products Product\[\]
orders OrderItem\[\]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[status\])
}
model Product {
id String @id @default(cuid())
name String
slug String @unique
description String @db.Text
vendorId String
vendor Vendor @relation(fields: \[vendorId\], references: \[id\], onDelete: Cascade)
categoryId String
category Category @relation(fields: \[categoryId\], references: \[id\])
basePrice Decimal @db.Decimal(10, 2\)
variants ProductVariant\[\]
images ProductImage\[\]
reviews Review\[\]
status ProductStatus @default(DRAFT)
featured Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[vendorId\])
@@index(\[categoryId\])
@@index(\[status\])
@@index(\[slug\])
}
model ProductVariant {
id String @id @default(cuid())
productId String
product Product @relation(fields: \[productId\], references: \[id\], onDelete: Cascade)
name String
sku String @unique
price Decimal @db.Decimal(10, 2\)
stock Int @default(0)
attributes Json // { size: "M", color: "Blue" }
cartItems CartItem\[\]
orderItems OrderItem\[\]
wishlist Wishlist\[\]
@@index(\[productId\])
@@index(\[sku\])
}
model CartItem {
id String @id @default(cuid())
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
variantId String
variant ProductVariant @relation(fields: \[variantId\], references: \[id\], onDelete: Cascade)
quantity Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique(\[userId, variantId\])
@@index(\[userId\])
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
userId String
user User @relation(fields: \[userId\], references: \[id\])
items OrderItem\[\]
subtotal Decimal @db.Decimal(10, 2\)
tax Decimal @db.Decimal(10, 2\)
shipping Decimal @db.Decimal(10, 2\)
total Decimal @db.Decimal(10, 2\)
status OrderStatus @default(PENDING)
shippingAddressId String
shippingAddress Address @relation(fields: \[shippingAddressId\], references: \[id\])
paymentMethod String
paymentStatus PaymentStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index(\[userId\])
@@index(\[orderNumber\])
@@index(\[status\])
}
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: \[orderId\], references: \[id\], onDelete: Cascade)
variantId String
variant ProductVariant @relation(fields: \[variantId\], references: \[id\])
vendorId String
vendor Vendor @relation(fields: \[vendorId\], references: \[id\])
quantity Int
price Decimal @db.Decimal(10, 2\)
status OrderItemStatus @default(PENDING)
@@index(\[orderId\])
@@index(\[vendorId\])
}
model Review {
id String @id @default(cuid())
productId String
product Product @relation(fields: \[productId\], references: \[id\], onDelete: Cascade)
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
rating Int // 1-5
title String
comment String @db.Text
verified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique(\[productId, userId\])
@@index(\[productId\])
}
model Address {
id String @id @default(cuid())
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
fullName String
phone String
street String
city String
state String
zipCode String
country String
isDefault Boolean @default(false)
orders Order\[\]
@@index(\[userId\])
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
parentId String?
parent Category? @relation("CategoryTree", fields: \[parentId\], references: \[id\])
children Category\[\] @relation("CategoryTree")
products Product\[\]
}
model Wishlist {
id String @id @default(cuid())
userId String
user User @relation(fields: \[userId\], references: \[id\], onDelete: Cascade)
variantId String
variant ProductVariant @relation(fields: \[variantId\], references: \[id\], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique(\[userId, variantId\])
@@index(\[userId\])
}
enum UserRole {
CUSTOMER
VENDOR
ADMIN
}
enum VendorStatus {
PENDING
APPROVED
SUSPENDED
}
enum ProductStatus {
DRAFT
ACTIVE
OUT_OF_STOCK
DISCONTINUED
}
enum OrderStatus {
PENDING
CONFIRMED
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
enum OrderItemStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
enum PaymentStatus {
PENDING
PAID
FAILED
REFUNDED
}Complex Features Implementation:
Cart Management:
// server/controllers/cartController.ts
export const cartController \= {
async getCart(c: Context) {
const user \= c.get('user')
const cart \= await prisma.cartItem.findMany({
where: { userId: user.id },
include: {
variant: {
include: {
product: {
include: {
vendor: {
select: {
id: true,
businessName: true,
},
},
images: {
take: 1,
},
},
},
},
},
},
})
const total \= cart.reduce((sum, item) \=\> {
return sum \+ (Number(item.variant.price) \* item.quantity)
}, 0\)
return c.json({
items: cart,
total,
count: cart.reduce((sum, item) \=\> sum \+ item.quantity, 0),
})
},
async addToCart(c: Context) {
const user \= c.get('user')
const { variantId, quantity \= 1 } \= await c.req.json()
// Check stock
const variant \= await prisma.productVariant.findUnique({
where: { id: variantId },
})
if (\!variant || variant.stock \< quantity) {
return c.json({ error: 'Insufficient stock' }, 400\)
}
// Add or update cart item
const cartItem \= await prisma.cartItem.upsert({
where: {
userId\_variantId: {
userId: user.id,
variantId,
},
},
update: {
quantity: { increment: quantity },
},
create: {
userId: user.id,
variantId,
quantity,
},
})
return c.json({ data: cartItem })
},
}Order Processing:
// server/controllers/orderController.ts
export const orderController \= {
async createOrder(c: Context) {
const user \= c.get('user')
const { shippingAddressId, paymentMethod } \= await c.req.json()
// Get cart items
const cartItems \= await prisma.cartItem.findMany({
where: { userId: user.id },
include: {
variant: {
include: {
product: {
include: {
vendor: true,
},
},
},
},
},
})
if (cartItems.length \=== 0\) {
return c.json({ error: 'Cart is empty' }, 400\)
}
// Calculate totals
const subtotal \= cartItems.reduce((sum, item) \=\> {
return sum \+ (Number(item.variant.price) \* item.quantity)
}, 0\)
const tax \= subtotal \* 0.1 // 10% tax
const shipping \= 10 // Flat shipping
const total \= subtotal \+ tax \+ shipping
// Create order in transaction
const order \= await prisma.$transaction(async (tx) \=\> {
// Create order
const order \= await tx.order.create({
data: {
orderNumber: \`ORD-${Date.now()}\`,
userId: user.id,
subtotal,
tax,
shipping,
total,
shippingAddressId,
paymentMethod,
items: {
create: cartItems.map(item \=\> ({
variantId: item.variantId,
vendorId: item.variant.product.vendorId,
quantity: item.quantity,
price: item.variant.price,
})),
},
},
include: {
items: {
include: {
variant: {
include: {
product: true,
},
},
},
},
shippingAddress: true,
},
})
// Update stock
for (const item of cartItems) {
await tx.productVariant.update({
where: { id: item.variantId },
data: {
stock: { decrement: item.quantity },
},
})
}
// Clear cart
await tx.cartItem.deleteMany({
where: { userId: user.id },
})
return order
})
// Send confirmation email
// await sendOrderConfirmation(order)
return c.json({ data: order }, 201\)
},
}Vendor Analytics:
// server/controllers/vendorController.ts
export const vendorController \= {
async getAnalytics(c: Context) {
const user \= c.get('user')
const vendor \= await prisma.vendor.findUnique({
where: { userId: user.id },
})
if (\!vendor) {
return c.json({ error: 'Vendor not found' }, 404\)
}
const \[
totalProducts,
totalOrders,
revenue,
recentOrders,
\] \= await Promise.all(\[
prisma.product.count({
where: { vendorId: vendor.id },
}),
prisma.orderItem.count({
where: { vendorId: vendor.id },
}),
prisma.orderItem.aggregate({
where: {
vendorId: vendor.id,
status: 'DELIVERED',
},
\_sum: {
price: true,
},
}),
prisma.orderItem.findMany({
where: { vendorId: vendor.id },
take: 10,
orderBy: { id: 'desc' },
include: {
order: {
include: {
user: {
select: {
name: true,
email: true,
},
},
},
},
variant: {
include: {
product: {
select: {
name: true,
},
},
},
},
},
}),
\])
return c.json({
totalProducts,
totalOrders,
revenue: revenue.\_sum.price || 0,
recentOrders,
})
},
}File Structure:
Backend:
server/
├── index.ts
├── routes/
│ ├── auth.ts
│ ├── products.ts
│ ├── vendors.ts
│ ├── cart.ts
│ ├── orders.ts
│ ├── reviews.ts
│ ├── admin.ts
│ └── upload.ts
├── controllers/
├── middleware/
└── lib/
Frontend:
app/
├── (auth)/
│ ├── login/
│ ├── register/
│ └── vendor/
│ └── register/
├── (main)/
│ ├── products/
│ │ ├── page.tsx
│ │ └── [slug]/
│ ├── cart/
│ ├── checkout/
│ ├── orders/
│ │ └── [id]/
│ └── wishlist/
├── (vendor)/
│ ├── dashboard/
│ ├── products/
│ ├── orders/
│ └── analytics/
└── (admin)/
├── dashboard/
├── vendors/
└── users/
Skills Applied (Everything):
- Full NHPC stack mastery
- Complex database design
- Multi-user system
- E-commerce logic
- Payment integration ready
- File uploads
- Search and filtering
- Analytics and reporting
- Role-based access control
- Transaction management
Deliverable: Production-ready marketplace platform
DEPLOYMENT
Deployment Guide
Backend (Hono) Deployment Options:
- Cloudflare Workers:
pnpm add wrangler \-D
npx wrangler init
npx wrangler deploy
- Vercel Edge:
// api/index.ts
import app from './server'
export default app
export const config \= {
runtime: 'edge',
}- Node.js (Traditional):
- Deploy to Railway, Render, or Fly.io
- Use PM2 for process management
Frontend (Next.js) Deployment:
Vercel (Recommended):
vercel;Database:
- Neon (Serverless PostgreSQL)
- Supabase
- PlanetScale
- Railway
Course Summary
What You've Built:
- Recipe Platform (Frontend)
- Blog API (Backend)
- E-commerce API (Backend)
- Job Board (Full-stack)
- Social Media (Full-stack)
- Marketplace (Full-stack Production)
Technologies Mastered:
✅ Next.js 15 - Modern React framework
✅ Hono - Ultra-fast API framework
✅ PostgreSQL - Relational database
✅ Prisma ORM - Type-safe database access
✅ TypeScript - End-to-end type safety
✅ Cloudflare/Vercel - Edge deployment
✅ Authentication - JWT, sessions
✅ File Uploads - Cloud storage
✅ Real-time - Polling, notifications
✅ E-commerce - Cart, orders, payments
Core Competencies:
- ✅ Build modern full-stack applications
- ✅ Design RESTful APIs
- ✅ Implement authentication systems
- ✅ Handle file uploads
- ✅ Create complex database schemas
- ✅ Deploy to production
- ✅ Follow best practices
- ✅ Write type-safe code
Why NHPC > MERN
| Feature | MERN | NHPC |
|---|---|---|
| Type Safety | ❌ | ✅ Full TypeScript |
| Performance | ⚡ Good | ⚡⚡ Excellent (Edge) |
| DX | 👍 Good | 👍👍 Excellent |
| Database | MongoDB | PostgreSQL (+ Prisma) |
| Backend | Express | Hono (faster) |
| Deployment | Traditional | Edge-first |
| Learning Curve | Moderate | Moderate |
| Modern | Established | Cutting-edge |
You're now a NHPC Stack developer! Ready to build modern, scalable, type-safe applications! 🚀
Course Created: November 2025
Start Date: December 2, 2025
End Date: December 30, 2025
Stack: Next.js + Hono + Prisma + Cloudflare (NHPC)

