A comprehensive Next.js 15 learning project demonstrating advanced framework features, modern React patterns, and real-time capabilities through an interactive IPL auction platform.
This project serves as a hands-on demonstration of Next.js 15's most powerful features. Built as a realistic IPL cricket auction platform, it showcases enterprise-level patterns, real-time data handling, and modern full-stack development practices.
- β Next.js 15 App Router architecture
- β Server Components vs Client Components patterns
- β Server Actions for type-safe mutations
- β Real-time updates with Server-Sent Events (SSE)
- β Optimistic UI updates and state management
- β Edge Runtime for low-latency APIs
- β React 19 features and patterns
- β TypeScript strict mode best practices
- β Modern UI with Tailwind CSS + Shadcn/UI
| Technology | Version | Purpose |
|---|---|---|
| Next.js | 15.0.0 | App Router, Server Components, Server Actions |
| React | 19.0.0 | UI library with latest features |
| TypeScript | 5.6.0 | Type safety in strict mode |
| TanStack Query | 5.59.0 | Client-side state management & caching |
| Tailwind CSS | 3.4.0 | Utility-first styling |
| Shadcn/UI | Latest | Pre-built accessible components |
| Zod | 3.23.8 | Runtime schema validation |
| Sonner | Latest | Toast notifications |
app/
βββ layout.tsx # Root layout with providers
βββ page.tsx # Homepage (Server Component)
βββ dashboard/ # Dashboard route group
β βββ page.tsx # Server Component with streaming
β βββ loading.tsx # Automatic loading UI
β βββ components/ # Feature components
βββ auction/
β βββ [auctionId]/ # Dynamic route with params
β βββ page.tsx # Server Component wrapper
β βββ actions.ts # Server Actions
β βββ live-bids.tsx # Client Component
βββ api/
βββ auction/
βββ live/
βββ route.ts # Edge Runtime SSE endpoint
Learning Points:
- File-system based routing with App Router
- Layout composition and nesting
- Dynamic route segments with
[param] - Route groups for organization
- API routes with Route Handlers
Files: app/dashboard/page.tsx, app/teams/page.tsx, app/players/page.tsx
// Server Component - runs only on server
export default async function DashboardPage() {
// Direct database access (no API needed)
const metrics = await getDashboardMetrics();
const teams = await getAllTeams();
return (
<div>
<Suspense fallback={<MetricsSkeleton />}>
<MetricCards metrics={metrics} />
</Suspense>
{/* ... */}
</div>
);
}Benefits Demonstrated:
- Zero client JavaScript for data fetching
- Direct database/API access
- Automatic code splitting
- Better SEO and initial load performance
- Reduced bundle size
Files: app/auction/[auctionId]/live-bids.tsx, app/dashboard/components/create-auction.tsx
'use client';
import { useState, useEffect } from 'react';
export function LiveBidComponent({ initialAuction }: Props) {
const [timeRemaining, setTimeRemaining] = useState(60);
// Client-side interactivity
useEffect(() => {
// EventSource for real-time updates
const eventSource = new EventSource(`/api/auction/live?auctionId=${auctionId}`);
// ...
}, []);
return (/* Interactive UI */);
}When to Use Client Components:
- Interactive UI with state (
useState,useEffect) - Event handlers (
onClick,onChange) - Browser APIs (
EventSource,localStorage) - Third-party libraries needing browser context
- Real-time updates and subscriptions
File: app/auction/[auctionId]/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function placeBidAction(
auctionId: string,
teamId: string,
amount: number
): Promise<ActionResponse<Auction>> {
// Zod validation
const validated = placeBidSchema.parse({ auctionId, teamId, amount });
// Database mutation with atomic locking
const result = await placeBid(validated.auctionId, validated.teamId, validated.amount);
// Revalidate cache
revalidatePath('/dashboard');
revalidatePath(`/auction/${auctionId}`);
return { success: true, data: result.auction };
}Key Concepts:
'use server'directive for server-only execution- Type-safe mutations without REST APIs
- Automatic POST endpoint generation
- Built-in CSRF protection
revalidatePath()for cache invalidation- Progressive enhancement (works without JS)
File: app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
{/* Stream different parts independently */}
<Suspense fallback={<Skeleton />}>
<MetricCard icon={Users} label="Total Teams" fetchData={getTeamCount} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<MetricCard icon={Trophy} label="Live Auctions" fetchData={getLiveCount} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}Benefits:
- Progressive rendering - show content as it loads
- Better perceived performance
- Parallel data fetching
- Granular loading states
- No client-side loading spinners needed
File: app/api/auction/live/route.ts
export const runtime = 'edge'; // Low latency
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// Send updates every second
const interval = setInterval(() => {
const auction = getAuctionById(auctionId);
const message = `data: ${JSON.stringify({ type: 'auction_update', auction })}\n\n`;
controller.enqueue(encoder.encode(message));
}, 1000);
// Cleanup on disconnect
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}Why SSE over WebSockets:
- Simpler for one-way serverβclient updates
- Automatic reconnection
- Works over HTTP/2
- Better for read-heavy real-time data
- Lower overhead than WebSockets
File: app/api/auction/live/route.ts
export const runtime = 'edge';Advantages:
- Deployed to edge locations globally
- <50ms cold start (vs Node.js ~500ms)
- Lower latency for API responses
- Ideal for real-time and streaming
- Cost-effective scaling
File: app/auction/[auctionId]/page.tsx
// Next.js 15: params is now a Promise
interface AuctionPageProps {
params: Promise<{ auctionId: string }>;
}
export default async function AuctionPage({ params }: AuctionPageProps) {
const { auctionId } = await params; // Must await!
const auction = getAuctionById(auctionId);
return <LiveBidComponent auctionId={auctionId} initialAuction={auction} />;
}Breaking Change in Next.js 15:
paramsis now async and must be awaited- Prevents accidental synchronous access
- Better type safety
File: app/auction/[auctionId]/live-bids.tsx
const bidMutation = useMutation({
mutationFn: ({ teamId, amount }) => placeBidAction(auctionId, teamId, amount),
onMutate: async ({ teamId, amount }) => {
// Cancel queries
await queryClient.cancelQueries(['auction', auctionId]);
// Snapshot previous state
const previous = queryClient.getQueryData(['auction', auctionId]);
// Optimistically update UI
queryClient.setQueryData(['auction', auctionId], {
...previous,
currentBid: amount,
currentBidder: teamId,
});
return { previous };
},
onError: (error, variables, context) => {
// Rollback on error
queryClient.setQueryData(['auction', auctionId], context.previous);
},
});Pattern Benefits:
- Instant UI feedback
- Better UX during network delays
- Automatic rollback on errors
- Combined with Server Actions
File: lib/validations.ts
import { z } from 'zod';
export const placeBidSchema = z.object({
auctionId: z.string().min(1),
teamId: z.string().min(1),
amount: z.number().positive(),
});
export type PlaceBidInput = z.infer<typeof placeBidSchema>;Integration with Server Actions:
- Runtime validation of inputs
- Type inference for TypeScript
- Automatic error messages
- Protection against invalid data
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser (Client) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Client Components β
β ββ LiveBidComponent (useState, useEffect, SSE) β
β ββ CreateAuctionButton (onClick handlers) β
β ββ TanStack Query (client cache) β
ββββββββββββββββ¬βββββββββββββββββββββ¬ββββββββββββββββββββββ
β β
Server Actions SSE Connection
β β
ββββββββββββββββΌβββββββββββββββββββββΌββββββββββββββββββββββ
β Next.js Server β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Server Components β
β ββ DashboardPage (async, direct DB access) β
β ββ TeamsPage (streaming with Suspense) β
β ββ PlayersPage (filtering, sorting) β
β β
β Server Actions β
β ββ placeBidAction (mutations, revalidation) β
β ββ completeAuctionAction (business logic) β
β ββ Zod validation β
β β
β Route Handlers (Edge Runtime) β
β ββ /api/auction/live (SSE stream) β
ββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββ
β
Direct Access
β
ββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββ
β In-Memory Database (globalThis) β
β ββ Teams (Map<string, Team>) β
β ββ Players (Map<string, Player>) β
β ββ Auctions (Map<string, Auction>) β
β ββ Atomic bid locks (race condition prevention) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
app/layout.tsx (Root)
ββ Providers (TanStack Query)
ββ Navigation
ββ Children
ββ app/page.tsx (Homepage - Server Component)
ββ app/dashboard/page.tsx (Server Component)
β ββ <Suspense> β MetricCard
β ββ <Suspense> β LiveAuctionsList
β ββ <Suspense> β ActivityFeed
β ββ CreateAuctionButton (Client)
ββ app/teams/page.tsx (Server Component)
ββ app/players/page.tsx (Server Component)
ββ app/auction/[auctionId]/page.tsx (Server Component)
ββ LiveBidComponent (Client)
ββ SSE connection
ββ TanStack Query
ββ Optimistic updates
ββ Server Actions
sequenceDiagram
User->>Dashboard: Click "Start Auction"
Dashboard->>Modal: Open player selection
User->>Modal: Select player
Modal->>Server Action: createAuctionAction(playerId)
Server Action->>Database: createAuction(playerId)
Database-->>Server Action: Auction object
Server Action->>Cache: revalidatePath('/dashboard')
Server Action-->>User: Redirect to /auction/[id]
Steps:
- User clicks "Start New Auction" button
- Client component opens modal with player list
- User selects a player
createAuctionActionServer Action called- Database creates auction with 60s timer
- Player status β "Live"
- Path revalidation updates dashboard
- User redirected to live auction page
sequenceDiagram
User->>LiveBid: Select team & amount
User->>LiveBid: Click "Place Bid"
LiveBid->>TanStack Query: Optimistic update
LiveBid->>UI: Show new bid immediately
LiveBid->>Server Action: placeBidAction(id, team, amount)
Server Action->>Zod: Validate input
Server Action->>Database: Acquire lock
Database->>Database: Validate bid > current
Database->>Database: Check team purse
Database->>Database: Update auction
Database-->>Server Action: Success
Server Action->>Cache: revalidatePath()
Server Action-->>LiveBid: Response
LiveBid->>TanStack Query: Confirm update
LiveBid->>User: Show success toast
Steps:
- User selects team and enters bid amount
- Click "Place Bid" button
- Optimistic Update: UI immediately shows the bid
placeBidActionServer Action called- Zod validates input data
- Database acquires atomic lock (prevents race conditions)
- Validates: bid > current bid, team has purse
- Updates auction state
- Releases lock
- Cache revalidated for fresh data
- Success/error response
- If error: TanStack Query rolls back to previous state
- Toast notification shown
sequenceDiagram
participant Browser1
participant Browser2
participant SSE Endpoint
participant Database
Browser1->>SSE Endpoint: Connect EventSource
Browser2->>SSE Endpoint: Connect EventSource
SSE Endpoint->>Database: Poll every 1s
Database-->>SSE Endpoint: Current auction state
SSE Endpoint-->>Browser1: data: { type: 'auction_update' }
SSE Endpoint-->>Browser2: data: { type: 'auction_update' }
SSE Endpoint-->>Browser1: data: { type: 'timer_update', timeRemaining: 45 }
SSE Endpoint-->>Browser2: data: { type: 'timer_update', timeRemaining: 45 }
Note over Browser1: User places bid
Browser1->>Server Action: placeBidAction()
Server Action->>Database: Update auction
SSE Endpoint->>Database: Next poll
Database-->>SSE Endpoint: Updated auction
SSE Endpoint-->>Browser1: New bid data
SSE Endpoint-->>Browser2: New bid data (real-time!)
Steps:
- Component mounts β Opens SSE connection
- Server streams events every second:
auction_update: Latest bid, current biddertimer_update: Countdown secondsauction_end: When timer hits 0
- When bid placed in another tab/user:
- Database updated
- SSE detects change on next poll
- Broadcasts to ALL connected clients
- Both tabs update simultaneously
- On timer = 0:
completeAuction()called automatically- Player sold to highest bidder (or unsold)
- Team purse updated
auction_endevent sent- SSE connection closed
sequenceDiagram
participant Timer
participant Database
participant SSE
participant UI
Timer->>Timer: Countdown: 3, 2, 1, 0
Timer->>Database: completeAuction(auctionId)
alt Has Bids
Database->>Database: Status β 'completed'
Database->>Database: Player status β 'Sold'
Database->>Database: Reduce team purse
Database->>Database: Add player to team
Database->>Database: Log activity
Database-->>SSE: Completed auction
SSE-->>UI: "SOLD to [Team] for βΉX Cr"
else No Bids
Database->>Database: Status β 'completed'
Database->>Database: Player status β 'Unsold'
Database->>Database: Log activity
Database-->>SSE: Completed auction
SSE-->>UI: "[Player] went unsold"
end
Auto-Completion Logic:
- Timer reaches 0 seconds
completeAuction()called- If bids exist:
- Player sold to highest bidder
- Team purse -= bid amount
- Player added to team roster
- Green "SOLD" banner shown
- If no bids:
- Player marked unsold
- Yellow "UNSOLD" banner shown
- Activity feed updated
- Dashboard metrics refreshed
// Using globalThis for persistence across hot reloads
const globalForDb = globalThis as unknown as {
teams: Map<string, Team>;
players: Map<string, Player>;
auctions: Map<string, Auction>;
activities: Activity[];
isDbInitialized: boolean;
};
const teams = globalForDb.teams || new Map<string, Team>();
const players = globalForDb.players || new Map<string, Player>();
const auctions = globalForDb.auctions || new Map<string, Auction>();Why globalThis:
- Persists data across Next.js hot reloads in dev mode
- Prevents auction data loss during development
- Single initialization across module imports
- Production: Migrate to PostgreSQL/MongoDB
interface Team {
id: string;
name: string;
shortName: string;
logo: string;
totalPurse: number; // βΉ100 Cr
remainingPurse: number; // Decreases with each player bought
maxPlayers: 25;
players: Player[];
}
interface Player {
id: string;
name: string;
role: 'Batsman' | 'Bowler' | 'All-Rounder' | 'Wicket-Keeper';
basePrice: number;
status: 'Unsold' | 'Live' | 'Sold';
nationality: string;
age: number;
teamId?: string; // Set when sold
soldPrice?: number; // Final bid amount
}
interface Auction {
id: string;
playerId: string;
player: Player;
currentBid: number;
currentBidder?: string; // Team ID
bids: Bid[];
status: 'live' | 'completed';
startTime: number; // Unix timestamp
endTime?: number;
timerDuration: number; // 60 seconds
}
interface Bid {
id: string;
auctionId: string;
playerId: string;
teamId: string;
teamName: string;
amount: number;
timestamp: number;
}const bidLocks = new Map<string, boolean>();
export async function placeBid(auctionId: string, teamId: string, amount: number) {
// Atomic lock acquisition
if (bidLocks.get(auctionId)) {
return { success: false, error: 'Another bid is being processed' };
}
bidLocks.set(auctionId, true);
try {
// Validate and update atomically
const auction = auctions.get(auctionId);
if (amount <= auction.currentBid) {
return { success: false, error: 'Bid too low' };
}
// Update
auction.currentBid = amount;
auction.currentBidder = teamId;
return { success: true, auction };
} finally {
bidLocks.delete(auctionId); // Always release lock
}
}node --version # v20.0.0 or higher
npm --version # v10.0.0 or higher# Clone the repository
git clone <repository-url>
cd nextjs-demo
# Install dependencies
npm install
# Start development server
npm run devhttp://localhost:3000
{
"dev": "next dev", // Development server with hot reload
"build": "next build", // Production build
"start": "next start", // Production server
"lint": "next lint" // ESLint checks
}- Explore homepage and navigation
- Create your first auction
- Place bids and watch real-time updates
- See how timer auto-completes auctions
- Study file structure and routing
- Compare Server vs Client Components
- Understand Server Actions vs API routes
- Analyze Suspense streaming pattern
- Implement SSE connection handling
- Study optimistic updates with TanStack Query
- Review race condition prevention
- Migrate to PostgreSQL with Prisma
-
Default to Server Components
- Use Client Components only when needed
- Reduces bundle size and improves performance
-
Server Actions over API Routes
- Type-safe mutations
- No API layer needed
- Automatic revalidation
-
Streaming for Better UX
- Use Suspense boundaries
- Progressive content loading
- Granular loading states
-
Edge Runtime for Real-Time
- Low-latency responses
- Global distribution
- Cost-effective scaling
-
Optimistic Updates
- Instant user feedback
- Better perceived performance
- Automatic error handling
| Pattern | Implementation | Use Case |
|---|---|---|
| Data Fetching | Server Component + async/await |
Initial page load |
| Mutations | Server Actions + revalidatePath() |
Form submissions, updates |
| Real-time | SSE Route Handler + EventSource | Live data streams |
| Client State | TanStack Query + Optimistic updates | Interactive UI |
| Validation | Zod schemas | Runtime type safety |
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_ENABLE_REALTIME=true
DATABASE_URL=postgresql://... # For production DB-
Vercel (Recommended)
vercel --prod
-
Docker
docker build -t ipl-auction . docker run -p 3000:3000 ipl-auction -
Self-Hosted
npm run build npm start
For production, migrate from in-memory to PostgreSQL:
npm install @prisma/client prisma
npx prisma init
npx prisma migrate dev- Next.js 15 Complete Guide
- React Server Components Deep Dive
- TypeScript for Next.js
- Real-Time Web Applications
MIT License - Educational purposes
Built with β€οΈ as a Next.js 15 course demonstration project
Client Components (Interactivity):
All mutations use Server Actions instead of REST APIs:
- app/auction/[auctionId]/actions.ts
placeBidAction()- Place a bid with validationcompleteAuctionAction()- Finalize auctiongetAuctionAction()- Fetch auction state
Edge Runtime SSE Handler:
- app/api/auction/live/route.ts
- Streams auction updates
- Timer synchronization
- Automatic cleanup on disconnect
Mock In-Memory Database:
- lib/db.ts
- 10 IPL teams with βΉ100Cr purse each
- 100 players with various roles
- Atomic bid operations (race condition prevention)
- Activity logging
# Install dependencies
npm install
# Run development server
npm run dev
# Build for production
npm run build
# Start production server
npm startnpm run devNavigate to http://localhost:3000
You can create an auction programmatically:
import { createAuction } from '@/lib/db';
// Create auction for player P001 with 60s timer
const auction = createAuction('P001', 60);
console.log(`Auction created: /auction/${auction.id}`);Or use the dashboard to view existing auctions.
- Navigate to a live auction
- Select a team
- Enter bid amount (must be higher than current bid)
- Submit bid
- Watch real-time updates!
nextjs-demo/
βββ app/
β βββ api/
β β βββ auction/
β β βββ live/
β β βββ route.ts # SSE handler (Edge Runtime)
β βββ auction/
β β βββ [auctionId]/
β β βββ actions.ts # Server Actions
β β βββ live-bids.tsx # Client Component
β β βββ page.tsx # Server Component
β βββ dashboard/
β β βββ components/ # Dashboard components
β β βββ loading.tsx # Loading skeleton
β β βββ page.tsx # Dashboard (PPR enabled)
β βββ players/
β β βββ page.tsx # Players listing
β βββ teams/
β β βββ page.tsx # Teams overview
β βββ layout.tsx # Root layout
β βββ globals.css # Global styles
β βββ page.tsx # Home (redirects)
β βββ providers.tsx # Client providers
βββ components/
β βββ ui/ # Shadcn/UI components
βββ lib/
β βββ db.ts # Mock database
β βββ types.ts # TypeScript types
β βββ validations.ts # Zod schemas
β βββ utils.ts # Utility functions
βββ next.config.ts # Next.js config (PPR enabled)
βββ tailwind.config.ts # Tailwind config
βββ tsconfig.json # TypeScript config (strict)
βββ package.json # Dependencies
Enabled in next.config.ts:
experimental: {
ppr: 'incremental',
}Used in app/dashboard/page.tsx:
export const experimental_ppr = true;Benefits:
- Static shell loads instantly
- Dynamic content streams in
- Best of both static and dynamic rendering
Dashboard uses Suspense boundaries for progressive rendering:
<Suspense fallback={<MetricSkeleton />}>
<MetricCard title="Total Teams" value={10} />
</Suspense>Each metric can load independently without blocking others.
Replace traditional API routes with direct server functions:
'use server';
export async function placeBidAction(
auctionId: string,
teamId: string,
amount: number
): Promise<ActionResponse<Auction>> {
// Validate, process, revalidate
}Benefits:
- Type-safe
- Automatic revalidation
- No API route boilerplate
- Direct database access
SSE handler uses Edge Runtime for low latency:
export const runtime = 'edge';Deployed to edge locations worldwide for faster real-time updates.
Minimal client components for interactivity:
'use client';
export function LiveBidComponent() {
// useState, useEffect, event handlers
}Only interactive parts are client-side, rest stays on server.
Bids use atomic locks to prevent double-spending:
const bidLocks = new Map<string, boolean>();
export async function placeBid(auctionId: string, teamId: string, amount: number) {
if (bidLocks.get(auctionId)) {
return { success: false, error: 'Another bid is being processed' };
}
bidLocks.set(auctionId, true);
try {
// Process bid atomically
} finally {
bidLocks.delete(auctionId);
}
}Instant feedback using TanStack Query:
const bidMutation = useMutation({
onMutate: async ({ teamId, amount }) => {
// Optimistically update UI before server confirms
queryClient.setQueryData(['auction'], optimisticAuction);
},
onError: (error, variables, context) => {
// Rollback on error
queryClient.setQueryData(['auction'], context.previousAuction);
},
});Client (Browser)
β
βββ Server Component (Initial Data)
β β
β Server Actions (Mutations)
β β
β Revalidate Cache
β
βββ SSE Connection (Edge Runtime)
β
Real-time Updates
β
TanStack Query Cache
- Open two browser windows side-by-side
- Navigate to the same auction in both
- Place a bid in one window
- Watch the other window update instantly!
Create .env.local:
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_ENABLE_REALTIME=true
NEXT_PUBLIC_ENABLE_OPTIMISTIC_UI=truenpm run build
vercel deploy --proddocker build -t ipl-auction .
docker run -p 3000:3000 ipl-auctionThis is a demonstration project. Feel free to fork and customize!
MIT License - feel free to use this as a learning resource or starter template.
Built with β€οΈ using Next.js 15