Next.js 15: Complete Step-by-Step Guide from Setup to Production
Build a complete task management app with Next.js 15, React 19 hooks, Turbopack, authentication, database, and deploy to production. Follow along from zero to live application.
Next.js 15 represents a revolutionary leap forward in web development. In this comprehensive guide, we'll build a complete task management application from scratch, taking you through every step from initial setup to production deployment. You'll learn React 19 hooks, Turbopack optimization, authentication, database integration, and modern deployment strategies.
What We'll Build: TaskFlow - A Complete Task Management App
Our production-ready application will include user authentication, real-time task management, optimistic UI updates, advanced caching, and deployment to Vercel. By the end of this guide, you'll have a fully functional app and deep understanding of Next.js 15.
Secure login/signup with better-auth and session management
Live task updates using Server-Sent Events and WebSockets
Instant feedback with React 19 useOptimistic hook
Complete CI/CD pipeline with Vercel and database hosting
Phase 1: Project Setup and Installation
Let's start by creating our Next.js 15 project with all necessary dependencies. We'll configure Turbopack for lightning-fast development and set up our development environment.
Step 1: Create Next.js 15 Project
# Create new Next.js 15 project with all modern featuresnpx create-next-app@latest taskflow-app \ --typescript \ --tailwind \ --eslint \ --app \ --src-dir \ --import-alias "@/*"# Navigate to project directorycd taskflow-app# Verify Next.js 15 installationnpm list next# Should show: next@15.x.x
Step 2: Install Dependencies
# Core dependencies for our full-stack appnpm install \ better-auth \ @better-fetch/fetch \ drizzle-orm \ postgres \ @neondatabase/serverless \ @paralleldrive/cuid2 \ bcryptjs \ zod \ react-hook-form \ @hookform/resolvers \ framer-motion \ lucide-react \ @radix-ui/react-dialog \ @radix-ui/react-dropdown-menu \ @radix-ui/react-toast \ @radix-ui/react-select \ date-fns \ @tanstack/react-query# Development dependenciesnpm install -D \ drizzle-kit \ @types/bcryptjs \ @types/node \ tsx \ @next/bundle-analyzer
Step 3: Configure Next.js 15 with Turbopack
Configure your project to leverage Next.js 15's performance improvements and new features:
import type { NextConfig } from 'next';const nextConfig: NextConfig = { // Enable Turbopack for development (10x faster than Webpack) experimental: { turbo: { resolveAlias: { '@': './src', '@/components': './src/components', '@/lib': './src/lib', '@/app': './src/app', '@/types': './src/types', }, // CSS optimization optimizeCss: true, // Tree shaking for smaller bundles treeShaking: true, // Module imports optimization modularizeImports: { 'lucide-react': { transform: 'lucide-react/dist/esm/icons/{{member}}', }, '@radix-ui/react-icons': { transform: '@radix-ui/react-icons/dist/{{member}}.js', }, }, }, }, // TypeScript configuration typescript: { tsconfigPath: './tsconfig.json', ignoreBuildErrors: false, }, // Image optimization for better performance images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, // Environment variables env: { BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, },};export default nextConfig;
{ "scripts": { "dev": "next dev --turbo", "dev:webpack": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", "analyze": "ANALYZE=true npm run build", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:migrate": "drizzle-kit migrate" }}
Phase 2: Database Setup with Drizzle ORM
Set up a PostgreSQL database with Drizzle ORM for our task management system. We'll create schemas for users, tasks, and sessions using Drizzle's type-safe approach.
Step 4: Initialize Database
# Create database directoriesmkdir -p src/dbmkdir -p drizzle# Initialize Drizzle configurationtouch drizzle.config.tstouch src/db/schema.tstouch src/db/index.ts# Create environment filetouch .env.local
import { defineConfig } from "drizzle-kit";export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL!, }, verbose: true, strict: true,});
import { pgTable, text, timestamp, boolean, pgEnum, index, uniqueIndex } from "drizzle-orm/pg-core";import { createId } from "@paralleldrive/cuid2";// Enumsexport const priorityEnum = pgEnum("priority", ["LOW", "MEDIUM", "HIGH", "URGENT"]);export const statusEnum = pgEnum("status", ["TODO", "IN_PROGRESS", "REVIEW", "COMPLETED"]);// Users tableexport const users = pgTable("users", { id: text("id").primaryKey().$defaultFn(() => createId()), email: text("email").notNull().unique(), name: text("name"), password: text("password"), avatar: text("avatar"), emailVerified: timestamp("email_verified"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),}, (table) => ({ emailIdx: uniqueIndex("users_email_idx").on(table.email),}));// Sessions table for better-authexport const sessions = pgTable("sessions", { id: text("id").primaryKey().$defaultFn(() => createId()), sessionToken: text("session_token").notNull().unique(), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), expires: timestamp("expires").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),}, (table) => ({ sessionTokenIdx: uniqueIndex("sessions_token_idx").on(table.sessionToken), userIdIdx: index("sessions_user_id_idx").on(table.userId),}));// Accounts table for OAuth providersexport const accounts = pgTable("accounts", { id: text("id").primaryKey().$defaultFn(() => createId()), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), type: text("type").notNull(), provider: text("provider").notNull(), providerAccountId: text("provider_account_id").notNull(), refreshToken: text("refresh_token"), accessToken: text("access_token"), expiresAt: timestamp("expires_at"), tokenType: text("token_type"), scope: text("scope"), idToken: text("id_token"), sessionState: text("session_state"), createdAt: timestamp("created_at").defaultNow().notNull(),}, (table) => ({ providerAccountIdx: uniqueIndex("accounts_provider_account_idx").on(table.provider, table.providerAccountId), userIdIdx: index("accounts_user_id_idx").on(table.userId),}));// Categories tableexport const categories = pgTable("categories", { id: text("id").primaryKey().$defaultFn(() => createId()), name: text("name").notNull(), color: text("color").notNull().default("#3B82F6"), createdAt: timestamp("created_at").defaultNow().notNull(),});// Tasks tableexport const tasks = pgTable("tasks", { id: text("id").primaryKey().$defaultFn(() => createId()), title: text("title").notNull(), description: text("description"), completed: boolean("completed").notNull().default(false), priority: priorityEnum("priority").notNull().default("MEDIUM"), status: statusEnum("status").notNull().default("TODO"), dueDate: timestamp("due_date"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), categoryId: text("category_id").references(() => categories.id),}, (table) => ({ userIdIdx: index("tasks_user_id_idx").on(table.userId), completedIdx: index("tasks_completed_idx").on(table.completed), priorityIdx: index("tasks_priority_idx").on(table.priority), categoryIdIdx: index("tasks_category_id_idx").on(table.categoryId),}));// Type exports for TypeScriptexport type User = typeof users.$inferSelect;export type NewUser = typeof users.$inferInsert;export type Session = typeof sessions.$inferSelect;export type NewSession = typeof sessions.$inferInsert;export type Account = typeof accounts.$inferSelect;export type NewAccount = typeof accounts.$inferInsert;export type Task = typeof tasks.$inferSelect;export type NewTask = typeof tasks.$inferInsert;export type Category = typeof categories.$inferSelect;export type NewCategory = typeof categories.$inferInsert;
import { drizzle } from "drizzle-orm/neon-serverless";import { Pool } from "@neondatabase/serverless";import * as schema from "./schema";// Create connection poolconst pool = new Pool({ connectionString: process.env.DATABASE_URL });// Create Drizzle instanceexport const db = drizzle(pool, { schema });// Export schema for easier importsexport { schema };export * from "./schema";
Step 5: Environment Configuration
# Database (for development, use local PostgreSQL or Docker)DATABASE_URL="postgresql://username:password@localhost:5432/taskflow"# For production, use Neon, Vercel Postgres or your preferred provider# DATABASE_URL="postgres://username:password@host:5432/database"# better-auth configurationBETTER_AUTH_SECRET="your-super-secret-key-here-change-this"BETTER_AUTH_URL="http://localhost:3000"# Optional: OAuth providers (Google, GitHub, etc.)GOOGLE_CLIENT_ID="your-google-client-id"GOOGLE_CLIENT_SECRET="your-google-client-secret"GITHUB_CLIENT_ID="your-github-client-id"GITHUB_CLIENT_SECRET="your-github-client-secret"
# Generate migration filesnpx drizzle-kit generate# Push schema to database (creates tables)npx drizzle-kit push# Optional: Open Drizzle Studio to view datanpx drizzle-kit studio
Phase 3: Authentication with better-auth
Implement secure authentication with email/password and OAuth providers using better-auth, a modern TypeScript-first authentication library.
Step 6: Configure better-auth
import { betterAuth } from "better-auth";import { drizzleAdapter } from "better-auth/adapters/drizzle";import { db } from "@/db";import { users, sessions, accounts } from "@/db/schema";export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: { users, sessions, accounts, }, }), emailAndPassword: { enabled: true, requireEmailVerification: false, // Set to true in production }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // 24 hours }, baseURL: process.env.BETTER_AUTH_URL, secret: process.env.BETTER_AUTH_SECRET, logger: { level: process.env.NODE_ENV === "development" ? "debug" : "error", },});export type Session = typeof auth.$Infer.Session;export type User = typeof auth.$Infer.User;
import { createAuthClient } from "better-auth/react";export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",});export const { signIn, signOut, signUp, useSession } = authClient;
Step 7: Create API Routes
import { auth } from "@/lib/auth";export const { GET, POST } = auth.handler;
Note: With better-auth, user registration is handled automatically through the authentication system. You can customize the registration flow by using better-auth's built-in signup functionality or create custom registration logic if needed.
Phase 4: Building the UI with React 19 Hooks
Create a modern, responsive UI using React 19 hooks, Tailwind CSS, and Radix UI components. We'll implement the new useActionState, useFormStatus, and useOptimistic hooks.
Step 8: Create Layout and Providers
import './globals.css'import type { Metadata } from 'next'import { Inter } from 'next/font/google'import { QueryProvider } from '@/components/providers/query-provider'import { ThemeProvider } from '@/components/providers/theme-provider'import { Toaster } from '@/components/ui/toaster'const inter = Inter({ subsets: ['latin'] })export const metadata: Metadata = { title: 'TaskFlow - Modern Task Management', description: 'A modern task management application built with Next.js 15',}export default function RootLayout({ children,}: { children: React.ReactNode}) { return ( <html lang="en" suppressHydrationWarning> <body className={inter.className}> <QueryProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} <Toaster /> </ThemeProvider> </QueryProvider> </body> </html> )}
Step 9: Implement React 19 useActionState Hook
Create a task creation form using the new useActionState hook for seamless form handling:
'use client'import { useActionState } from 'react'import { useFormStatus } from 'react-dom'import { createTask } from '@/app/actions/tasks'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { Textarea } from '@/components/ui/textarea'import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'import { Loader2, Plus } from 'lucide-react'function SubmitButton() { const { pending } = useFormStatus() return ( <Button type="submit" disabled={pending} className="w-full"> {pending ? ( <> <Loader2 className="w-4 h-4 mr-2 animate-spin" /> Creating task... </> ) : ( <> <Plus className="w-4 h-4 mr-2" /> Create Task </> )} </Button> )}export function TaskForm() { const [state, formAction] = useActionState(createTask, { message: '', errors: {}, }) return ( <form action={formAction} className="space-y-4"> <div> <Input name="title" placeholder="Task title" required className="w-full" /> {state.errors?.title && ( <p className="text-sm text-red-600 mt-1">{state.errors.title}</p> )} </div> <div> <Textarea name="description" placeholder="Task description (optional)" className="w-full" /> {state.errors?.description && ( <p className="text-sm text-red-600 mt-1">{state.errors.description}</p> )} </div> <div className="grid grid-cols-2 gap-4"> <div> <Select name="priority" defaultValue="MEDIUM"> <SelectTrigger> <SelectValue placeholder="Priority" /> </SelectTrigger> <SelectContent> <SelectItem value="LOW">Low</SelectItem> <SelectItem value="MEDIUM">Medium</SelectItem> <SelectItem value="HIGH">High</SelectItem> <SelectItem value="URGENT">Urgent</SelectItem> </SelectContent> </Select> </div> <div> <Input name="dueDate" type="date" className="w-full" /> </div> </div> <SubmitButton /> {state.message && ( <div className="p-3 bg-green-50 border border-green-200 rounded-md"> <p className="text-green-800 text-sm">{state.message}</p> </div> )} {state.errors?._form && ( <div className="p-3 bg-red-50 border border-red-200 rounded-md"> <p className="text-red-800 text-sm">{state.errors._form}</p> </div> )} </form> )}
Step 10: Server Actions with Enhanced Error Handling
'use server'import { z } from 'zod'import { revalidatePath } from 'next/cache'import { auth } from '@/lib/auth'import { db } from '@/db'import { tasks } from '@/db/schema'import { eq, and } from 'drizzle-orm'import { headers } from 'next/headers'const createTaskSchema = z.object({ title: z.string().min(1, 'Title is required').max(100, 'Title too long'), description: z.string().max(500, 'Description too long').optional(), priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).default('MEDIUM'), dueDate: z.string().optional(),})export async function createTask(prevState: any, formData: FormData) { const session = await auth.api.getSession({ headers: await headers(), }) if (!session?.user?.id) { return { errors: { _form: 'You must be logged in to create tasks' }, message: '', } } const validatedFields = createTaskSchema.safeParse({ title: formData.get('title'), description: formData.get('description'), priority: formData.get('priority'), dueDate: formData.get('dueDate'), }) if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: '', } } const { title, description, priority, dueDate } = validatedFields.data try { await db.insert(tasks).values({ title, description: description || null, priority, dueDate: dueDate ? new Date(dueDate) : null, userId: session.user.id, }) revalidatePath('/dashboard') return { message: 'Task created successfully!', errors: {}, } } catch (error) { console.error('Failed to create task:', error) return { errors: { _form: 'Failed to create task. Please try again.' }, message: '', } }}export async function toggleTask(taskId: string) { const session = await auth.api.getSession({ headers: await headers(), }) if (!session?.user?.id) { throw new Error('Unauthorized') } try { const task = await db.select({ userId: tasks.userId, completed: tasks.completed }) .from(tasks) .where(eq(tasks.id, taskId)) .limit(1) if (!task[0] || task[0].userId !== session.user.id) { throw new Error('Task not found or unauthorized') } await db.update(tasks) .set({ completed: !task[0].completed, updatedAt: new Date() }) .where(eq(tasks.id, taskId)) revalidatePath('/dashboard') } catch (error) { console.error('Failed to toggle task:', error) throw error }}export async function deleteTask(taskId: string) { const session = await auth.api.getSession({ headers: await headers(), }) if (!session?.user?.id) { throw new Error('Unauthorized') } try { const task = await db.select({ userId: tasks.userId }) .from(tasks) .where(eq(tasks.id, taskId)) .limit(1) if (!task[0] || task[0].userId !== session.user.id) { throw new Error('Task not found or unauthorized') } await db.delete(tasks).where(eq(tasks.id, taskId)) revalidatePath('/dashboard') } catch (error) { console.error('Failed to delete task:', error) throw error }}
Step 11: Optimistic UI with useOptimistic Hook
Implement instant UI updates using React 19's useOptimistic hook:
'use client'import { useOptimistic, useTransition } from 'react'import { Task } from '@/db/schema'import { toggleTask, deleteTask } from '@/app/actions/tasks'import { Button } from '@/components/ui/button'import { Checkbox } from '@/components/ui/checkbox'import { Trash2, Calendar, Flag } from 'lucide-react'import { format } from 'date-fns'interface TaskListProps { initialTasks: Task[]}type OptimisticAction = | { type: 'toggle'; id: string } | { type: 'delete'; id: string }export function TaskList({ initialTasks }: TaskListProps) { const [isPending, startTransition] = useTransition() const [optimisticTasks, addOptimisticTask] = useOptimistic( initialTasks, (state, action: OptimisticAction) => { switch (action.type) { case 'toggle': return state.map(task => task.id === action.id ? { ...task, completed: !task.completed } : task ) case 'delete': return state.filter(task => task.id !== action.id) default: return state } } ) const handleToggle = (taskId: string) => { startTransition(() => { addOptimisticTask({ type: 'toggle', id: taskId }) toggleTask(taskId) }) } const handleDelete = (taskId: string) => { startTransition(() => { addOptimisticTask({ type: 'delete', id: taskId }) deleteTask(taskId) }) } const getPriorityColor = (priority: string) => { switch (priority) { case 'URGENT': return 'text-red-600' case 'HIGH': return 'text-orange-600' case 'MEDIUM': return 'text-yellow-600' case 'LOW': return 'text-green-600' default: return 'text-gray-600' } } const getPriorityBorder = (priority: string) => { switch (priority) { case 'URGENT': return 'border-l-red-500' case 'HIGH': return 'border-l-orange-500' case 'MEDIUM': return 'border-l-yellow-500' case 'LOW': return 'border-l-green-500' default: return 'border-l-gray-500' } } if (optimisticTasks.length === 0) { return ( <div className="text-center py-12 text-gray-500"> <p className="text-lg">No tasks yet!</p> <p className="text-sm">Create your first task to get started.</p> </div> ) } return ( <div className="space-y-3"> {optimisticTasks.map((task) => ( <div key={task.id} className={`relative p-4 bg-white dark:bg-gray-800 rounded-lg border-l-4 shadow-sm transition-all duration-200 ${ getPriorityBorder(task.priority) } ${ task.completed ? 'opacity-75' : '' } ${ isPending ? 'opacity-50 pointer-events-none' : '' }`} > <div className="flex items-start gap-3"> <Checkbox checked={task.completed} onCheckedChange={() => handleToggle(task.id)} className="mt-1" /> <div className="flex-1 min-w-0"> <h3 className={`font-medium ${ task.completed ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-white' }`}> {task.title} </h3> {task.description && ( <p className={`text-sm mt-1 ${ task.completed ? 'line-through text-gray-400 dark:text-gray-500' : 'text-gray-600 dark:text-gray-300' }`}> {task.description} </p> )} <div className="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400"> <div className={`flex items-center gap-1 ${getPriorityColor(task.priority)}`}> <Flag className="w-3 h-3" /> <span className="capitalize">{task.priority.toLowerCase()}</span> </div> {task.dueDate && ( <div className="flex items-center gap-1"> <Calendar className="w-3 h-3" /> <span>{format(new Date(task.dueDate), 'MMM d, yyyy')}</span> </div> )} <span> Created {format(new Date(task.createdAt), 'MMM d')} </span> </div> </div> <Button variant="ghost" size="sm" onClick={() => handleDelete(task.id)} className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20" > <Trash2 className="w-4 h-4" /> </Button> </div> </div> ))} {isPending && ( <div className="text-center py-2 text-sm text-gray-500"> Saving changes... </div> )} </div> )}
Phase 5: Production Deployment
Deploy your application to production with Vercel, set up a production database, and configure environment variables for a live application.
Step 12: Prepare for Production
# Type check the entire projectnpm run type-check# Lint and fix issuesnpm run lint# Test production build locallynpm run buildnpm run start# Analyze bundle sizenpm run analyze
Step 13: Deploy to Vercel
# Install Vercel CLInpm i -g vercel# Login to Vercelvercel login# Deploy to productionvercel --prod# Or connect GitHub repository for automatic deployments# 1. Push code to GitHub# 2. Import project in Vercel dashboard# 3. Configure environment variables# 4. Deploy automatically on every push
Step 14: Production Database Setup
Set up a production PostgreSQL database with Vercel Postgres or your preferred provider:
# Create Vercel Postgres databasevercel storage create postgres --type postgres# Get connection stringvercel env pull .env.production# Run database migration in productionnpx prisma db push --schema=./prisma/schema.prisma
Step 15: Environment Variables for Production
Production Environment Variables
# Set environment variables in Vercel dashboard or CLIvercel env add DATABASE_URLvercel env add BETTER_AUTH_SECRETvercel env add BETTER_AUTH_URLvercel env add GOOGLE_CLIENT_IDvercel env add GOOGLE_CLIENT_SECRETvercel env add GITHUB_CLIENT_IDvercel env add GITHUB_CLIENT_SECRET# Deploy with new environment variablesvercel --prod
Performance Optimizations and Best Practices
Implement advanced performance optimizations and monitoring for your production application:
Implement intelligent caching with Next.js 15 fetch configurations
Set up error tracking and performance monitoring
Configure metadata, sitemaps, and search engine optimization
Implement security headers, CSRF protection, and rate limiting
// Next.js 15 explicit caching strategiesexport async function getTasks(userId: string) { const response = await fetch(`${process.env.NEXTAUTH_URL}/api/tasks?userId=${userId}`, { // Cache for 5 minutes with background revalidation next: { revalidate: 300, tags: [`tasks-${userId}`] }, cache: 'force-cache' }) return response.json()}// Cache invalidation on mutationsimport { revalidateTag } from 'next/cache'export async function invalidateUserTasks(userId: string) { revalidateTag(`tasks-${userId}`)}
Congratulations! You've built a complete, production-ready task management application with Next.js 15. Your app includes modern React 19 hooks, optimistic UI updates, secure authentication, database integration, and is deployed to production with proper caching and performance optimizations.
Next Steps and Advanced Features
Take your application further with these advanced features:
- Real-time collaboration with WebSockets or Server-Sent Events
- Advanced filtering and search with full-text search
- File attachments and image uploads with Vercel Blob
- Mobile app with React Native and shared API
- Team collaboration features with role-based permissions
- Advanced analytics and user behavior tracking
- Progressive Web App (PWA) capabilities
- Internationalization (i18n) for global users
You now have a solid foundation in Next.js 15 and modern React development. The patterns and techniques you've learned can be applied to any full-stack application. Keep building, keep learning, and keep pushing the boundaries of what's possible with modern web development!